diff --git a/Cargo.lock b/Cargo.lock index 64fb6204bb..3b55fb526e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9399,6 +9399,7 @@ dependencies = [ name = "nsh-base" version = "0.3.0" dependencies = [ + "chrono", "html5ever 0.27.0", "imara-diff", "log", @@ -9468,6 +9469,7 @@ dependencies = [ "tokio-tungstenite 0.28.0", "unicode-linebreak", "unicode-segmentation", + "ureq", "url", "webpki-roots 1.0.11", "windows-sys 0.61.2", @@ -9579,7 +9581,6 @@ dependencies = [ "ttf-parser", "unicode-bidi", "unicode-linebreak", - "ureq", "windows-sys 0.61.2", "x11rb", "zbus", diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json index 1f6d04f5f2..84195055ec 100644 --- a/products/maka/cli/notices.json +++ b/products/maka/cli/notices.json @@ -1,17 +1,17 @@ { "inputs": { - "Cargo.lock": "d397a9d90075250fe8572f19c5eec98f3fbe661d01d498e78e65918eafad0b85", + "Cargo.lock": "0c4df4b7fdb6ab8ab15ee24e083bb1cab4f41f74daa9445ef5416b204ed81401", "Cargo.toml": "0ac2ccacded4494795f64c9fb00ab70eee642fddc059293beae570c6aa2eb531", "package-lock.json": "863e20d94f46507befd14d7abe94982bb110dff3efaa13b9d1c3b60333f8d64a", "package.json": "a513ba49c73a1cd6d8060d9a4f2edb47f5286469553cf3b3f4959b9ceb64fa58", - "packages/plugin-sdk/src/composer-view.ts": "fe9dd6320ecf4ef715b9c987ab1d4940bfdccb3b249307d6d076578eece6d18c", + "packages/plugin-sdk/src/composer-view.ts": "67a3873beb21778ddbba561f949ebe840c6af33fa0cdb3f3a9da8f6fb4cb03d3", "packages/plugin-sdk/src/configurations.ts": "5cd4b679d786a4766f97460c8fbe070a92415b5210561344c71de4f61c471f5e", "packages/plugin-sdk/src/contracts.ts": "63134585c74106b6dbf6d149598cfb2aa93f76fdfe86beed62d859bbb66291ca", "packages/plugin-sdk/src/generated/methods.ts": "83c5100a927372eeb44f53a714f0796bb812a74979f290b170026752536ad370", "packages/plugin-sdk/src/generated/runtime.ts": "9508dc5cda141731e27eacaeaec3a268e3dd0617b6804b33ce5c3142678b1f0c", "packages/plugin-sdk/src/index.ts": "813f3f63b2bc47a4ce3fc9e1deb9fcad2f51dd13a26b37eb1341202e0804dc81", "packages/plugin-sdk/src/native.ts": "6a354ce1068d3eb288ebaf4e3f0e4a2ef00479690e895e53fa4be3f1789fb620", - "packages/plugin-sdk/src/workbench-views.ts": "8bf10f6ca049508dbbdbbcca448687ff6f753059cf3abb6e2ad3a32b4244ac9a", + "packages/plugin-sdk/src/workbench-views.ts": "5b0e829bc8df658994187384a5788227c578437e15fc1cb3082915009856e08c", "packages/plugin-sdk/src/workbench.ts": "1366b0bd2bb76c292720ca7352e64c413dbf14d75fba9281eb8a100083b13c0a", "packages/plugin-sdk/src/workspace-view.ts": "860bd38f16a18a061355521da3d25fa30e8ec4afa7c933fb725db95a5310d2db", "patches/zod+4.6.7.patch": "5a6cbff1b31ca2a5d2b7c006abb7b1002241dbf4ae42c315c27940201f52b081", @@ -527,16 +527,16 @@ "third-party/image-webp/src/vp8_arithmetic_decoder.rs": "baf4ac638decf5b302118f1dfa22d4b568acabc6e60f4b4847324b30a10433cb", "third-party/image-webp/src/yuv.rs": "2804fe9694a811d6d583c0191ba1c84692f2ca44d67a01f29598f76956bc0a62", "third-party/image-webp/upstream.json": "30a5ee0d62f7b73cb544f9b8d8df2f14b3f916157a6a2503d27e952d18ae215c", - "third-party/nsh/AGENTS.md": "55a6c78bfdbfc80953865bb1908b86cd69d3cb252c65c9309d99466217b7befd", + "third-party/nsh/AGENTS.md": "80a415a19ea869d4a76fb76aa8de10059bace154dadfe755f4b42aba7df5e00b", "third-party/nsh/CLAUDE.md": "336cc4fbf19beaada7ccf9986414fa91851a8d7a07dfb3ccbe800a69eed0ab49", - "third-party/nsh/CONTRIBUTING.md": "52b9bca9bacf5d3a1c3142161d7399ca11944129fac60488c485cd3ec2f3c276", - "third-party/nsh/Cargo.lock": "9a9ca1ad70b32ea1aa04f318b077e41af23bf581e25f46187352e2093e20bd59", - "third-party/nsh/Cargo.toml": "9970687042808c95919365864e31ec769b6b31dedf822a91f0e03a2bf213760c", + "third-party/nsh/CONTRIBUTING.md": "2f5615bb206548af037828905dc533ed52cc652c312dd81852ea91619aafb881", + "third-party/nsh/Cargo.lock": "7470535cd08153b6de06f66f45f878b637794d30c36d55800f85e044c7fa6a80", + "third-party/nsh/Cargo.toml": "25b8c6dc87977b07a422fd87d2640a2c250fd6aae6b533d0b4a6c28f2a8e82fb", "third-party/nsh/LICENSE-APACHE": "ec75427ad955e3c740f22e7dc3f6db6fc5b2cc1d9135c2af91cece8dd47a2d38", "third-party/nsh/README.md": "7e96dcaff053bd42b852ac90e14c2d2deed5e607630f5697284106de14c386d1", - "third-party/nsh/SOURCE.md": "c1c76184ced18761aeaade5656a2c4a0fac63108d6df0c2c3418cc8873543c02", - "third-party/nsh/crates/gpui/Cargo.lock": "e87d9c291aa1a10d642a786ce3ccf9026ec3cb056aac5a78e1d4fca24416ca1b", - "third-party/nsh/crates/gpui/Cargo.toml": "86334fb046b23a5c770758926bbd377408ce27ac4b97d9f239c075056b61e12b", + "third-party/nsh/SOURCE.md": "8f9b622108c3b1539ad5977ddbc832d0788b3731388a5b82a2c97e5b46be2dc5", + "third-party/nsh/crates/gpui/Cargo.lock": "1e0ae16130bfdf52e9234af20588d39be0c66988eb6b1c584ff1e12c64005da9", + "third-party/nsh/crates/gpui/Cargo.toml": "2cbea9fb1d709fa1ac007133bad9c099658c0d062040e517e5d9111ba3acc4bc", "third-party/nsh/crates/gpui/nsh-gpui-host/Cargo.toml": "f36b982a17b38bd1d91760a2d8d9db06b42db949c227878cef294a5ff2100844", "third-party/nsh/crates/gpui/nsh-gpui-host/src/lib.rs": "01aa2aa276788646e256b563b82df623731bcdd8d8827fd8ab18f711be80e9c9", "third-party/nsh/crates/gpui/nsh-gpui-host/src/main.rs": "90cc675d4e2d70cf54f61b4cc5b28af0167d895e76242e93865fdebfd440d3ae", @@ -549,13 +549,13 @@ "third-party/nsh/crates/gpui/nsh-gpui-terminal/src/platform.rs": "0cecd816c93bc053b1a617affa0bce4bf001cae43ebd7c483401f77a0b5dab44", "third-party/nsh/crates/gpui/nsh-gpui-terminal/src/window.rs": "2703c59cee703cf5bfd7d8e463a2a7745286594da07c4e78f09bfcced5e12182", "third-party/nsh/crates/gpui/nsh-gpui-terminal/src/wire.rs": "6144e0c6a8012c73969ef78826b0c098d884018c8c3ce7646021bdf51447f27e", - "third-party/nsh/crates/gpui/nsh-platform-gpui/Cargo.toml": "522e6a54515ac02ef052be3bc263bb8fa52454367974daa16a68623cd2616f6a", + "third-party/nsh/crates/gpui/nsh-platform-gpui/Cargo.toml": "41abdf0e65984021657281861d85efac971045ebc8fe4d5322550ca43f3ed4b3", "third-party/nsh/crates/gpui/nsh-platform-gpui/examples/embed.rs": "871f462beb8f992fc493fd33592b1cc8aaa711f026115d055f3ac94f7c934d6f", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/access.rs": "a158bc52852627311a95c6dfc802d964d0b804a59806b456248ce639008c7d17", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/access.rs": "453ccac2e089eac1bac199227df409386a88f7f987696a16efbb4bfe457f92c1", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/agent.rs": "249dbf3091c3cce8e1ea425e8f1c915c13f29878ff88c4e494e3d96265c7ce38", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/animation.rs": "eca3121f02c97d815c8ef98ba26b37eb8fa6ecbe4af1b98dacd4eab6a09f2682", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs": "5ded72ae94cce194b6006d591978bf8e123b0aa4457fa7662183554f544ebb8d", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/canvas.rs": "fc25fa52fbe8fe59b16ebf971c81a1bddc68e78181df2181a5620db24abc6518", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs": "da5b6a5da714887233ad960ba47c0ebbb546f439a4e211a95836e1067f5070a3", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/canvas.rs": "a5ba3866f979cc3f6818569a581d6b475c27b413f859e36264de4b7feaccef1f", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/chrome.rs": "fb824678bc4c5c56368fba9dd158f568cc043247bfdc91c576d294d9e3f725dd", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/chrome/menus.rs": "43b4c8cef09fb4d314e6f908b8f57df821ab6eaa9d236e0087db6a0599ae3ab7", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/decor.rs": "ff99994c61c1887046c52bf9243f065695d322a9433581148a0cefd0afae181b", @@ -566,15 +566,15 @@ "third-party/nsh/crates/gpui/nsh-platform-gpui/src/file_drop.rs": "c28da7dbe366411f41225ae85845ad5e1590a24c49baec826fd4449cd71d736e", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/focus_return.rs": "4b11b2b66c0199f6cfec054a8448cfa1d72a7f34b9dbad8aba806046edeb7507", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/geom.rs": "c3ec43a6f17f80371760c5f6cc2f8170fe0cc543bbda7094d683b3fbb287413e", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs": "fd0f95fcfa2dada529b5234a0f295217bcd1ff6764ccc1edf91b92b0d129d12b", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs": "918829dd464996fec0771e609a7e5402f42c4a31fd9454798e7b70838bda7d1c", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/input.rs": "c2318c2ea861b72ce246cd518f174e4e615bbc2f55c9825c3e4a9091b326c905", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/keymap.rs": "28e9e405053d019429ed8ded59d05d605312150fb31d5ada309878eb3b28babb", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/keys.rs": "588934210e4de9cfc849f0a66d48af29e3637a33f019a084db8961db0df46561", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/lib.rs": "72a27232fd34f6163fd26f719b89421e34b81c15e882f760a686b34d98c031d4", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/mount.rs": "2580b946fb45f41cc7f46e46ff6c6366963a8c90a76f95527a403e5734c6fb2a", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/mount.rs": "39ce56e85c06bd1a01527e575614f51d00d1916e6c3b3f55131d3536c5fe1265", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/native.rs": "039b7ee43931045daee513e8c9df4b2339a0064f36bca9ed915283c6745b05cf", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/native/video.rs": "e1a61eee03f49428c3860c2c732baa88709ac07fb8e837e0b09ba387f3473049", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/native/web.rs": "47b3c35e9e8654c485cd90b7e72d2ca5fba13a136868c8681e0e41be5a32baf4", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/native/web.rs": "e7a9f2d16a935e27b8de529d282ea323a0355c39ad7190b6fbdced7378bc7c08", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/navigation.rs": "5536fd67495499455446cdcb058947a56025e6ced333da5ac415c3d41bd379b9", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/render.rs": "52c772aebec6e1fe20fc4f8cfc6551069903130f5a6887405d63682b40b23459", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/rt.rs": "217dc97d29ceadb5cb4baa8aa8747a50c1a752b5771cc1e299a815d48beb180e", @@ -582,31 +582,37 @@ "third-party/nsh/crates/gpui/nsh-platform-gpui/src/selectable_view.rs": "35fe55fb4936385b3909ed6252e4e680eb125e07c54864b13bedea2e9fb6e3bf", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/shader.rs": "21174d671b1bcdffb29ac01d0a80ce826c9bd98b5748f5af8f514b663250fcf8", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/stats.rs": "b05468754e418be8b4fb4506b6036cd2b96c9f6f484fa5607b0fab9db74ab44e", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs": "af5cb749322111cd1de9bc4c75772c325284d94d658e0b24c8464df586471a40", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs": "4e82505fb9981e7525b250e38d87598f7053ef0b84e2703e701e222cce600bfc", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/synth.rs": "d6c43385fcf80d88679f55720befe2bb3e1b3cc0f8cae0d053db6f482955672e", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/system.rs": "6e83e43279fb2789e23f8422c8dcb33460634967fd79808b5622edd73d3265b0", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/system.rs": "1a6f6f4cd0e301bc4c937701bef7d223fdeb06f5491bbfb7f38384db886afbd4", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/tests.rs": "96e704532dd85d5e830ef78d515d853f5640b3876d0efbbb1a039b783f82e26b", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/text.rs": "8f03520f9a3199c53abc4bfa8eedccbfd55e5b4f7a08ed5691203bced68906d7", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/text.rs": "98ce1af056eea3149b954d9ec7a546dac3c07ef2e9a57265bbdb448d5cf48369", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/text_input.rs": "e4bb4669918cac11a52eb7e4a5393e6e71b384b0f6a173175b631208708d02ae", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/text_input/layout.rs": "b1611d4b806b05874d812767f46287766993d9e9ae88ffc996b869e5ecabbd97", - "third-party/nsh/crates/gpui/nsh-platform-gpui/src/transition.rs": "780c1e42a5482a3a965ba66cdbedabab915ed9b3449f57aceecb847afa383f18", + "third-party/nsh/crates/gpui/nsh-platform-gpui/src/transition.rs": "589f8784284909694788508c9a3bc3dd5984cd513b43280b526d54a96405363d", "third-party/nsh/crates/gpui/nsh-platform-gpui/src/view.rs": "04d80edc4118a4795c4db49963c55752f8c67fde4e9ab376ef211f2e4bcaa200", - "third-party/nsh/crates/nsh-base/Cargo.toml": "8f40d6a858e92cf367cf3239f96d8b579f1fb1b66accb9804ad40847b10f62c0", - "third-party/nsh/crates/nsh-base/src/alert_dialog.rs": "78e3b18f32a15213c0ee0d47e8bced6faf6f45ce919826b234884b3f90a53d6c", - "third-party/nsh/crates/nsh-base/src/button.rs": "4382db19f60d81fe8365dc00de374b4a1c6020a713e2a5653cf547c27f1b8062", + "third-party/nsh/crates/nsh-base/Cargo.toml": "76b96193e16c2265ba9d9d1100e5f7e8d0b9e840cb576553cc6a45be2e2d9102", + "third-party/nsh/crates/nsh-base/src/accordion.rs": "bef46ccacab77a31cb66610b2a50f01dc577e91e143559bfe49f33f3979b7b42", + "third-party/nsh/crates/nsh-base/src/alert_dialog.rs": "e76c300f8e6e27aca740b80192cb1b4099de3038395fd3de93daccd5badf2426", + "third-party/nsh/crates/nsh-base/src/avatar.rs": "7ed9818b57b6476c4fd9f213c35f2814120fec0586ebb9c0243af0922bb06a11", + "third-party/nsh/crates/nsh-base/src/button.rs": "a8650d354a27a6a30d860890a501ebb67db3165c05ebad852b551cedc717b8ef", + "third-party/nsh/crates/nsh-base/src/calendar.rs": "b0108bc87bf183589326e32d6111060852b181708a079befd7f515df00ba6c54", "third-party/nsh/crates/nsh-base/src/checkbox.rs": "c66cb0344a13a788843f46a265552f7460dcf2dd2014d274a4a6d92002418220", - "third-party/nsh/crates/nsh-base/src/collapsible.rs": "a2e8c871dcff95ad74237f355e971cf162062abde352894319f5950585fbf9e7", - "third-party/nsh/crates/nsh-base/src/combobox.rs": "c3a228546b125e8367b108a7654df5065f9acc1524f18aa7f2c15db8e7c1e38e", - "third-party/nsh/crates/nsh-base/src/data_table/axis.rs": "4abf3c7b6a691efbb391bd3339b4a12d6f77fc7acb0bf760b53c08095142bd0d", - "third-party/nsh/crates/nsh-base/src/data_table/columns.rs": "ffa3b86a4c0d4838808905f32ed054c174eb94d26119b5f520f1e3fe2939d4ac", - "third-party/nsh/crates/nsh-base/src/data_table/controller.rs": "a7580d935af134f26896772637bdee98cb5c39ffacc407b21961889babbd1e81", - "third-party/nsh/crates/nsh-base/src/data_table/data.rs": "109c335253cfd91607c63a0b12de04fd8dc61b5feaee17f904bd6df9170a9dbd", - "third-party/nsh/crates/nsh-base/src/data_table/mod.rs": "d57df9354d1e2024639bde458ab828260672ee4f73f634a05c1135ccc04d52a9", + "third-party/nsh/crates/nsh-base/src/collapsible.rs": "39f9020a4abc062bc6cd26bae3083288795be13b336630939003c40a8f3a9248", + "third-party/nsh/crates/nsh-base/src/color_picker/mod.rs": "e5a385b7fbbac1c807121ababf81c4b5a60272dc3c4327a9afc1f851d3a52ff2", + "third-party/nsh/crates/nsh-base/src/color_picker/model.rs": "33bb459aad2658a1b1543e369a99c870d27fd3d53dba761cc93dd077e5e4b877", + "third-party/nsh/crates/nsh-base/src/combobox.rs": "9f73839d617ec62a05f076daf6f95f97ba00b760be22b4c3f8354084f684e987", + "third-party/nsh/crates/nsh-base/src/data_table/axis.rs": "24dde88d8b02cbf916bf17727955f0e90aef5c8881593b7e5b637648d1f1f8e1", + "third-party/nsh/crates/nsh-base/src/data_table/columns.rs": "4f5e57e277a506b37be2ec63e390c4197c50f6f81ca7dcb4ead2b464a2ebd6aa", + "third-party/nsh/crates/nsh-base/src/data_table/controller.rs": "adc3e8123ca838edc16d15300434d05645255f9ed3ed488a4ce76cb29c50ea32", + "third-party/nsh/crates/nsh-base/src/data_table/data.rs": "eefad7d27ecaf42af38f4e79569500de843e29e2c50ad1b3ccabf4bc8563dbc3", + "third-party/nsh/crates/nsh-base/src/data_table/mod.rs": "19766f4c31a2efb2abf3bd591d870496d1abb3d36c4b1e0b2c9461a39b1de031", "third-party/nsh/crates/nsh-base/src/data_table/parts.rs": "1d2362d3b040e61b246747d98a33835d574271ca68a42efe9609cd3fc6df6c76", "third-party/nsh/crates/nsh-base/src/data_table/selection.rs": "9588028be5e17fbf971757315b58c4191c8474bc6c65a08ecd2748e8025cdb99", - "third-party/nsh/crates/nsh-base/src/data_table/view.rs": "dfb97adf692c87e138bfaff03b5ec1eab7f817b17f7cd6ea558aa17aaef4297e", + "third-party/nsh/crates/nsh-base/src/data_table/view.rs": "5e69267e64ae44cfc6d470403d9f5c8c1b30f08ac188406f21f2836ac167b553", + "third-party/nsh/crates/nsh-base/src/date_picker.rs": "6077c353eb83fd718bb2a08a6c150424ac509ef29513ace9700011f42f9e7a0b", "third-party/nsh/crates/nsh-base/src/default-theme.json": "8792c42d1057a7472b07062609061216ab3ea0b7137b853fb88858f78701320a", - "third-party/nsh/crates/nsh-base/src/diff/controller.rs": "0f2c67db9a2b4581acc7b31cd70c64fc9a0bbf48a50a16e00ba4e62f6b3cb058", + "third-party/nsh/crates/nsh-base/src/diff/controller.rs": "952e29df0c634a54c227570ffaf684b302ff30cc436cd3fbd1eaa73ab74c21ab", "third-party/nsh/crates/nsh-base/src/diff/files.rs": "f4050e2c29b723e8097073e6388d21baa973c98962e3013f654a1bc0eff722b6", "third-party/nsh/crates/nsh-base/src/diff/geometry.rs": "f22abc73deee6a1fafd9a3a3f30d8da574765a7fe71d28529632094bb47d4b3c", "third-party/nsh/crates/nsh-base/src/diff/layout.rs": "30c77978f894be186e27fee323e832c0165484cd0aa2b82160fac405ba799bcb", @@ -618,7 +624,7 @@ "third-party/nsh/crates/nsh-base/src/diff/view.rs": "2da19fd75999f8c90903a85f51d9fa071c2f1b65e280860a05f2290ff554ad3b", "third-party/nsh/crates/nsh-base/src/dock/area.rs": "93b20762c9f3f9fce24924a639603f70a252ecc44f0588a7d0cc25fa8c74a31e", "third-party/nsh/crates/nsh-base/src/dock/compact.rs": "f009fb4ff1f882db13bdf9b2f004fbf75a89dff1d1da631ed68bb528f69c6288", - "third-party/nsh/crates/nsh-base/src/dock/dock_area.rs": "9ff66584e2052241c652b5ddac386cc25d195ca31157b4fdd5e2ea6d7df759ff", + "third-party/nsh/crates/nsh-base/src/dock/dock_area.rs": "df4d5a0e823a87e92b3a8596d091c93cecca1e3caa38c4176408b69c37f46ecd", "third-party/nsh/crates/nsh-base/src/dock/dock_placement.rs": "7f4d1446913c0bd4c8c1370776f8b6105f43f5c7160e80f586eda2db71984333", "third-party/nsh/crates/nsh-base/src/dock/drag.rs": "cb71e56d9c38fe703bc59f67ff6b2f2514f9ea6076a225e19e6f0caf7d7ca0b2", "third-party/nsh/crates/nsh-base/src/dock/fixtures/bare_tab_panel_root.json": "3e6a100a3870a5b2aeac1d14eb312432515cc9ae529253bf9be13e8c48179e04", @@ -636,22 +642,22 @@ "third-party/nsh/crates/nsh-base/src/dock/mod.rs": "e1b0da24f9c5d831b5cf2371b9182a4ec573746483570a93425ce1648aaf2d9b", "third-party/nsh/crates/nsh-base/src/dock/state.rs": "cacd1b94610069f5e7fab6868b75c3cb86861ed60fc5bbf4e49e4bf038b19ade", "third-party/nsh/crates/nsh-base/src/dock/state_convert.rs": "3fd3d0fb3a7f8e66478aaa82a38fedca60f41ea31a67cd1fd32e06ec84e787ac", - "third-party/nsh/crates/nsh-base/src/dock/tab_group.rs": "32bb9853feda91ff60e17210419c87dcfc575e7868c40891ef30e6acb4f99b89", + "third-party/nsh/crates/nsh-base/src/dock/tab_group.rs": "52c114c960275ce7be63ce384e7b7ba6e84c28f3e580278399ecd072b1f3aaae", "third-party/nsh/crates/nsh-base/src/dock/value.rs": "424be4a73ffa43d578d474af6a13e9975b0b1583052c49c6b7a5dba3ef5d6009", "third-party/nsh/crates/nsh-base/src/dock/value/tests.rs": "d3077460e7855989e43c490df83dce0b830701f6ae13ece403dfd1d900443112", "third-party/nsh/crates/nsh-base/src/editor/app_highlight.rs": "052cd12983da940c2721c4d8566ee9614e628f64a0d1f5deace6e6807d91368d", "third-party/nsh/crates/nsh-base/src/editor/buffer.rs": "99fb4dc425d46880d9a539cddf998a4326978c4864eaa529e9d96d61a76adaf6", - "third-party/nsh/crates/nsh-base/src/editor/commands.rs": "b98082812a9bdbb778ad4fb858c1ed3a1be8f309d129e2d521a266f08eb3ee02", + "third-party/nsh/crates/nsh-base/src/editor/commands.rs": "bd44345e313ba0c4ce674dfe67afe2ba28aae24d69745d06596dc1e9b5e7fdf7", "third-party/nsh/crates/nsh-base/src/editor/commands/selecting.rs": "5462dacdba7738c21abbfbcdc62f34c8e62a2a4a9e5ea23558c06c7cd93f5c5d", "third-party/nsh/crates/nsh-base/src/editor/commands/transforms.rs": "0c8caf401a4bcc5285edadf58da8d854afaef16b5f96ecdc035b0457eabb47b2", - "third-party/nsh/crates/nsh-base/src/editor/completion.rs": "a17f192799efb327ac49f22da8982742e9ae710ec841cafe461cede722163851", - "third-party/nsh/crates/nsh-base/src/editor/controller.rs": "fbdbecdf76a53a0f91628e9c81d2ea7ebb050328de9e44a358ff09a0829beb25", + "third-party/nsh/crates/nsh-base/src/editor/completion.rs": "c2822c0c73ba60e886a8705ff626fa95300999286ce70fdb5498df3681535f60", + "third-party/nsh/crates/nsh-base/src/editor/controller.rs": "9ff72ccb23f88f52bbdf8ce8e169236c0a82d0fbc6b64bdaae29f32517dda12d", "third-party/nsh/crates/nsh-base/src/editor/data.rs": "75e3d8509689c60b6099bf6e32f8dfb1d21905d20e38c6af69c2b5ab88268a1f", "third-party/nsh/crates/nsh-base/src/editor/definition.rs": "3c204b82f6254ce14e0bc1b4966000526d7bdf9a3040c9b8bd882460e5b34862", - "third-party/nsh/crates/nsh-base/src/editor/diagnostics.rs": "282f1653e79aa836efda1c88fdd846959dee148fba7b06c4b049d38ece31dcf9", - "third-party/nsh/crates/nsh-base/src/editor/display.rs": "9f239c2dd6dd74fa28a17de6e2d3d4eb34c9855bf256c529380171a12b276c07", - "third-party/nsh/crates/nsh-base/src/editor/fold.rs": "f9157e805e51001409f462f4fce1ef1885d1a3c28af0439b9f4b9c1aa39b46c6", - "third-party/nsh/crates/nsh-base/src/editor/fold_set.rs": "b6af2c183a2fe4cd4797387e1087ea5678579748e6292a83e07c85b30f85f2a7", + "third-party/nsh/crates/nsh-base/src/editor/diagnostics.rs": "67d1ea6c2e80df86de948fc0e5f71d64fabc341c4e30e752b54942331f94bb0d", + "third-party/nsh/crates/nsh-base/src/editor/display.rs": "c7771df0de48bab462551effbff03d75a99aab701b30b0db62adcf14551013e7", + "third-party/nsh/crates/nsh-base/src/editor/fold.rs": "5059cce380220e1e913a8ea06cc98741bb3e17c8d3d724fafa5d071009d5a7e8", + "third-party/nsh/crates/nsh-base/src/editor/fold_set.rs": "524830995020c98ca04fa39b03b122bceed6cf692f151d31af9512b5ef305168", "third-party/nsh/crates/nsh-base/src/editor/highlight.rs": "bce97c799c991d39a2f2fd6f9d6c354631afa6972d6656d5c0452b7e2828822f", "third-party/nsh/crates/nsh-base/src/editor/highlighter.rs": "800f2fba3107ba22818ba7dc70955e970a99f5a75dbd1fc468be614e8cd98a21", "third-party/nsh/crates/nsh-base/src/editor/hooks.rs": "c25e059fa82cdb38bb147da10907234d40b462fbcb93e1a857a1cccc3f179ba3", @@ -659,59 +665,69 @@ "third-party/nsh/crates/nsh-base/src/editor/language.rs": "70b9f412d30ce13b234bd00fd7c42a0d9be6e2f8c3a3af7344aeea3256de53b6", "third-party/nsh/crates/nsh-base/src/editor/languages.rs": "3943fe2b6975160f6e798624b7699fce9ee0791f6295e718152a1eded10717ef", "third-party/nsh/crates/nsh-base/src/editor/layout.rs": "6fd96f24b2b25c60f6b76706cfa0716a65ffe78aac340ef0387132be5164772d", - "third-party/nsh/crates/nsh-base/src/editor/mod.rs": "0637eb3047cf76f6ebc03df29eb1bf393e864868fe4409bf63be3e24a06ff3a7", + "third-party/nsh/crates/nsh-base/src/editor/mod.rs": "989adacdf9f0747c2e9fddbe202f02a1b7f0fb43f6a650e86ee5ead4da73fb7e", "third-party/nsh/crates/nsh-base/src/editor/movement.rs": "34e3c264b8b99224380bb5aa5dbf8ddff6d0a3e5074728b8224d70e6b7ad6a52", "third-party/nsh/crates/nsh-base/src/editor/parts.rs": "466d44a08d77cd832c2f9cc2b3a0cdf7be4dd58a9e518b4074ae0b6515d5f970", - "third-party/nsh/crates/nsh-base/src/editor/search.rs": "81fa64d87b3476aafb497f96eacd988a8b60457461886969b948c8268de8d852", + "third-party/nsh/crates/nsh-base/src/editor/search.rs": "4138605e1596678ade46019cd274778cae1665bc70c8f970be66d3e10dc4131d", "third-party/nsh/crates/nsh-base/src/editor/select.rs": "967b51cc1935bd2984108bfc568cdcab3d1126a1dadb7fa691909076c4875613", - "third-party/nsh/crates/nsh-base/src/editor/state.rs": "b17edb90695dfaf3ab91b9d9c5f65048265bb788af9b4e1331a8a8315a822986", + "third-party/nsh/crates/nsh-base/src/editor/state.rs": "b13b2d1e9e3e35371c11373e815032f2e37766de4f2a47df2ab906e3788b030b", "third-party/nsh/crates/nsh-base/src/editor/touch.rs": "a6a58e32c2677243ef6ff24c6b5d6d5114547b9f174ce7b339faaaa52465a9e6", - "third-party/nsh/crates/nsh-base/src/editor/types.rs": "1151dc9a5ed8a3e4312fc394219659edb5045f0214fa51d113f56b808ecd565d", - "third-party/nsh/crates/nsh-base/src/editor/view.rs": "3aac0ccb327e5f1a7e48ede1efac410f37370b5c2b883cca9789c77f630330b7", + "third-party/nsh/crates/nsh-base/src/editor/types.rs": "98a4cc7ceed5f4b6334f9323ecac29d5d4cf0a937ddce5b85e5ee7321af57fba", + "third-party/nsh/crates/nsh-base/src/editor/view.rs": "b6b2a17ce95b5d876e3024cf1ae24ee20f96c960229368e3f046a64d298ca548", "third-party/nsh/crates/nsh-base/src/keys.rs": "74c2f44c17ec75451677c9b511a5789ad3dede3749fb82dfa5bea6864b63cab1", - "third-party/nsh/crates/nsh-base/src/lib.rs": "ea9388a6b2079099f26c11812add0f47a348689727655ba89c13f9900f701b4c", - "third-party/nsh/crates/nsh-base/src/listbox.rs": "e134a1906d8de2fc132ab39c98f9d718586d5453d24d8e6c81f666f01738adf2", - "third-party/nsh/crates/nsh-base/src/menu.rs": "7def4a987c0d1389266ab2eb52e7dd80ef5b7e0e5914d73eed57cbecdca72848", + "third-party/nsh/crates/nsh-base/src/lib.rs": "7049ae92fbfb961b2cdb81b54f5245d129105acddde202599e170242324d5a85", + "third-party/nsh/crates/nsh-base/src/link.rs": "363c8767eb6ab353edc8dac1b05bf814c77722593dba015a535a1f1309647b25", + "third-party/nsh/crates/nsh-base/src/listbox.rs": "fa1bc92c6c90c5fd0ab9955206daefd1ad376ff825c6ab929736a94e0be21c85", + "third-party/nsh/crates/nsh-base/src/menu.rs": "31dbfc530888b9c3ed7c17e67ab2e3b8eefed7d5fb64a448babb005bb8301c45", "third-party/nsh/crates/nsh-base/src/navigation.rs": "eabc4455bb49cc82945f26953c880ad5afcc1c612b11912ff34b63ad24dfa790", - "third-party/nsh/crates/nsh-base/src/number_input.rs": "5686c128bd32ec44e360f77c8dd84c2648730cde81cd3c6b19adb0bcda459dae", - "third-party/nsh/crates/nsh-base/src/numeric.rs": "9056f40e18a80bab0453e7b035aba14b42b8aaf86441ffd8a15e240e436f9695", - "third-party/nsh/crates/nsh-base/src/otp_input.rs": "c82ac6af598eeca4285b0c99f7f6768e27eebcf8e71b11799cc596c8ce751956", - "third-party/nsh/crates/nsh-base/src/plot/area.rs": "2fcb01ae0a92f4beb1fe59fb23f2db0fb4f20896f0f2b451c194fa8d0d227aea", + "third-party/nsh/crates/nsh-base/src/number_input.rs": "62c3e20438a2d5f0d3fcc9290c312992901286b9b03c86d319515087496dcb95", + "third-party/nsh/crates/nsh-base/src/numeric.rs": "d8560ee6513cb4241d3d26dbe9a45102e2fc50f96a6b7ff178e54ae87d39e636", + "third-party/nsh/crates/nsh-base/src/otp_input.rs": "e7b2f8ac1f54a027934b202d9fb4067050585ed0213c7a8830dbc91dfeca2cc9", + "third-party/nsh/crates/nsh-base/src/pagination.rs": "fedee50a6c4cb40452f1141824b3aea491c28226f375bf251b1efbdb3b6221d6", + "third-party/nsh/crates/nsh-base/src/plot/appear.rs": "0a404849c8beed59767ceb209df936b7f3081512a695964f89f8ff803dc6a201", + "third-party/nsh/crates/nsh-base/src/plot/area.rs": "3ca6213e4e01a3eae63313f8dfad4df32a2800906d94335a4b0c607cdc572ed2", "third-party/nsh/crates/nsh-base/src/plot/axis.rs": "47e85f79a7193b1b434ab040861e17f1995ca5174d76867f973b2a3a7577b098", - "third-party/nsh/crates/nsh-base/src/plot/bar.rs": "6f33798c2856cc9015db19441adacbd6c87cb300635f71d3541df4f361422b82", + "third-party/nsh/crates/nsh-base/src/plot/bar.rs": "341a60d14df6f33d79b699574ef4e4163262954ab3950af49c87283c30f42ab7", + "third-party/nsh/crates/nsh-base/src/plot/class.rs": "aa932247899d0f6a25c17aea9bee25563c628d847b0f7024cdb63a477d747652", "third-party/nsh/crates/nsh-base/src/plot/grid.rs": "bc0ddc47f24b2dcea055cd1fc34c589720fd0a0d8111adfde6a1e34f3bb36354", "third-party/nsh/crates/nsh-base/src/plot/keys.rs": "81b2323fc2b309f9f62228bec7497bab4bef7452dd4215f958920334794ebdf8", - "third-party/nsh/crates/nsh-base/src/plot/line.rs": "e77cc01137a41842663f59154da9fe50a2bbeabafe49ec9d396933424859414b", - "third-party/nsh/crates/nsh-base/src/plot/mod.rs": "d652ac0b99a1ee27244c475547c986abd1520438a897f6ce29a7639b7ef1acde", - "third-party/nsh/crates/nsh-base/src/plot/pie.rs": "7bedaffdff1ee9d497a2ec492620ed4ae2404a180a631e56a0b87639336da2eb", - "third-party/nsh/crates/nsh-base/src/plot/scale.rs": "05d475d462cc4f04985a62a91a1acf24b6d30d7ffac71d27eefdff07114a7462", - "third-party/nsh/crates/nsh-base/src/plot/series.rs": "cab91bbe41dfb7a96274e51dadf2ffd2ca277f8c49cee8b0f130f3da647c8360", + "third-party/nsh/crates/nsh-base/src/plot/label.rs": "ee22eb67be4e509f9f62cbd72b34631adbf4bd905e0d61ad2372e23881c83dcb", + "third-party/nsh/crates/nsh-base/src/plot/line.rs": "d927ecf572f138add8abd357658b759ec0ad98d77ab9cfd070e34bd3d3c812ec", + "third-party/nsh/crates/nsh-base/src/plot/mod.rs": "344c45cb64a018dcbb08c19c65bdac2d73ed78e93909c5b0aaeb55897a976ad3", + "third-party/nsh/crates/nsh-base/src/plot/pie.rs": "c43fdf15d7532cddc25971e7800b6fb3bcf2103083ba0cfbd6f7aa195de5bf95", + "third-party/nsh/crates/nsh-base/src/plot/radial_line.rs": "93b60fc2a7c8890944cfa35a7104d936e88c6a17ff2f2f7776eafcaf9d193a2c", + "third-party/nsh/crates/nsh-base/src/plot/sankey/label.rs": "92369eb2f56d47bc2048f280c435766ee09918d5c69ce665c51e5eaac9f68755", + "third-party/nsh/crates/nsh-base/src/plot/sankey/layout.rs": "754d707090ebed49e5e7f017a9358cb24bfb3861a0519834690c296bed244a1f", + "third-party/nsh/crates/nsh-base/src/plot/sankey/mod.rs": "e841a681a844618a6da61ef96e6e294557e5a0cd21c2373332025903d4ea0f90", + "third-party/nsh/crates/nsh-base/src/plot/sankey/pose.rs": "0703aa7c3e84b93cb3ba1e3cde584a10fa70e875b8ddfabcbfadd858defc7510", + "third-party/nsh/crates/nsh-base/src/plot/scale.rs": "685c0574de85927e1699db6874c85f5bd189dbfb989123c32d7c2813101de0db", + "third-party/nsh/crates/nsh-base/src/plot/series.rs": "6bee1b2bba3bb6325e98f28a63f9303b7c78e10ffe90a4eb2570abfe4893eb4f", "third-party/nsh/crates/nsh-base/src/plot/tween.rs": "e1fede84aa96b277f2227e65bb753907218d7275fa8ae0bfa50b7945910cfcce", - "third-party/nsh/crates/nsh-base/src/popover.rs": "588fc0bf8b9ed5f094a56dd6aab070f7d58be8ec946c20ff16147fad795dbbe3", - "third-party/nsh/crates/nsh-base/src/popup.rs": "07c6ed0bfb375a6e177b350f7afc94f99e0880159fdd4cc9bbe5363ff50e25e7", + "third-party/nsh/crates/nsh-base/src/popover.rs": "5971e7b7af886839a65eac73d273325cb1c6a9b49f9122a3b8741a0a108fad10", + "third-party/nsh/crates/nsh-base/src/popup.rs": "ff00221bb00dd9de2a2fe084c71ce6b810c25471d84c8c2fdace15b28a24b017", "third-party/nsh/crates/nsh-base/src/progress.rs": "add57fa912d552e633f9a4a95cd650b0be9f4dec11dbf241fe9e2e7fe27a7501", "third-party/nsh/crates/nsh-base/src/radio.rs": "b1aa3da65b3313f8da0113d89a4e4ad90ca8ce1958a97dba2f49669dc0c1c18a", "third-party/nsh/crates/nsh-base/src/resizable/mod.rs": "b2626676c04b3926ea89ff747abb60f801f48797000efec6fbf4f1c611b50515", - "third-party/nsh/crates/nsh-base/src/resizable/panel.rs": "405941402bece467fcc11aec057632795ae7c1f30ee48e5b3827d83df1a10068", + "third-party/nsh/crates/nsh-base/src/resizable/panel.rs": "e3a4a5fc85d00339d6dc5ebfe0239cccf8674ddc7e59c2b2f5018c90d3b49f70", "third-party/nsh/crates/nsh-base/src/resizable/resize_handle.rs": "0c889510a5588cc972f9e08cc340409ff6294dd50b779201b24def8f1902e37f", "third-party/nsh/crates/nsh-base/src/resizable/state.rs": "874194e34f301181eeffb2d88884fad8000878dba733255da02aee841aa50b8a", "third-party/nsh/crates/nsh-base/src/rich_editor/commands/base.rs": "4da9ca15c205f8101e894b2478f985df18b49954fbd11f26facf473f9baba106", "third-party/nsh/crates/nsh-base/src/rich_editor/commands/helpers.rs": "d12274f35990551fc451a67ea92c6f115fe45c19b616ee0b1ab31283d2dfdb74", "third-party/nsh/crates/nsh-base/src/rich_editor/commands/list.rs": "bb8d38ddfb24cffac44a77eb155e343a596cb6b147a9f0a47c8dda6dc30095b9", "third-party/nsh/crates/nsh-base/src/rich_editor/commands/mod.rs": "3da66bcf637c773db4a44692c1b9d38e33132742652b9462f97223811a243ea4", - "third-party/nsh/crates/nsh-base/src/rich_editor/commands/registry.rs": "1c9719ba881f4afa760c4f6ea5218efd1f21e60f2f5c82a8ab1f70879b7fd706", - "third-party/nsh/crates/nsh-base/src/rich_editor/commands/tiptap.rs": "145969b39ae409aa9001b151467a10c40833506de80d52a1510d309c92b9cb4d", + "third-party/nsh/crates/nsh-base/src/rich_editor/commands/registry.rs": "3d9d8a1c8c50a13be236d9dd3cc66f2f33e4f77397faa7cbd9aa1736f5c83d9b", + "third-party/nsh/crates/nsh-base/src/rich_editor/commands/tiptap.rs": "1cae718b255d091bcf526eaefe579a8a0a60b084c3471133f53e08c9e3281727", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/character_count.rs": "d5b701094aed56a09a585a956c903f178ace715616cf6e6b7906c042efacd3a2", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/clear_document.rs": "74b9014fe38e165e900d835a87fee73693f4261de6993fdfa80e36e96e724012", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/code_block.rs": "8f4959fda2060efd0a67580b4567cd9b535f39187274ab6c98d0077ed4f9eba9", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/core_keymap.rs": "d8c0c7fe1025ee235326a4f71549ab5d5b2a5ea0c11eaa1f05bfbf0cde802af7", - "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/extension.rs": "b7bffd458ef21a4f4ad2e128cdb64efc53f58fd4bacaccf95c170423b908b653", + "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/extension.rs": "c053f489c9179c6bc6a902181a134025ae1d20320c4f68ac6ee58dfbdd53331d", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/helpers.rs": "15ac08ac72a62f2a683e0846d23430383c02e77651c476fec2fa55e6915498e6", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/history.rs": "c8fe91af87f14122edb8097e0ef9ea6e5895e619b12472fe1eee85166eee4449", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/input_rules.rs": "50b0346ce3692033f105387f101cf2951eb84ca83d0820c25c21a25afc82fafc", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/keymap.rs": "48a1c2b2a3dd61f49ca18ba38bbb6c945251c2749fcef7dd2e31031ae30a3ad8", - "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/kit.rs": "c4a1a7ddba46011f3079aa6ea7fd10f98854bb9d930a2a9a9bc941da74d30502", - "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/link.rs": "5f95cb50fead659a4a7438fe6ae15facb748f91c12c9ea524d696f6c62827f5f", + "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/kit.rs": "cccf76528f048deb02a6f77c8fbd85e076bb5e15824175c6086c94f84e99d233", + "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/link.rs": "72d04edb9f09325ce36cc88a2efd7180d52aee48fc3769c7190af8b51ff12521", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/list_keymap.rs": "5c7db2b01b46f9b493703fd1bdfd1dbd8838cb7bb825f9e43ad756450620fcd0", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/list_scenarios.rs": "89a82432b9809702ded3584317f2d4a6237be552c1f12d061f841883a5bc2558", "third-party/nsh/crates/nsh-base/src/rich_editor/extensions/markdown.rs": "10031d5bcfcc996107988892af4f1be8daf93c217b4b6d614d42d0205e9e53a9", @@ -729,36 +745,36 @@ "third-party/nsh/crates/nsh-base/src/rich_editor/formats/markdown.rs": "8cc5a14795614d05e14784d8f4206a81b783ed03ff4a346b316f75a94f55f34a", "third-party/nsh/crates/nsh-base/src/rich_editor/formats/mod.rs": "e7c35f2dc182bc8fa094651f23d0b6774d3db75070bb703a9f35eaed484e52d6", "third-party/nsh/crates/nsh-base/src/rich_editor/formats/text.rs": "20b28ebc19eacdb7a7c10b586d383001a28aca8ad894effe55bbff98315bb7f6", - "third-party/nsh/crates/nsh-base/src/rich_editor/js/commands.rs": "9cb6500aaf8584e541ce568c4e2b595fe9e3e9e7d1dfb97533c820fd3248b137", - "third-party/nsh/crates/nsh-base/src/rich_editor/js/extension_api.rs": "416116cf5823026a3bdda3fcb169544a906d04a183bdfe284568ee43db19196a", + "third-party/nsh/crates/nsh-base/src/rich_editor/js/commands.rs": "70b2c01f45f9c4aae6f4e3048b983e183de7176519f3e519df6a63e9e3d4c08e", + "third-party/nsh/crates/nsh-base/src/rich_editor/js/extension_api.rs": "8e7a1f7181fae33135dc9ae2be623ea533b87578f005cfac1919164e70b31e0c", "third-party/nsh/crates/nsh-base/src/rich_editor/js/extensions.rs": "90a0bd76fe5a4aae0f6d9535dd59a604f63a590eada6eb5143daf01468ea1db1", "third-party/nsh/crates/nsh-base/src/rich_editor/js/json.rs": "4b5bc6e804f9b2850c82503a88a44bca3c9c531779cb15e40b1f1b5cb999df9a", - "third-party/nsh/crates/nsh-base/src/rich_editor/js/mod.rs": "2ed8d9fe9c5c517c94be7260fb32c7140c754468341746b911c789a9ea35ec2a", - "third-party/nsh/crates/nsh-base/src/rich_editor/js/plugins.rs": "4a8aa934fc37c2d18c037c9c0e1c1fc150b80e1d4470106368607e9f7283c10d", + "third-party/nsh/crates/nsh-base/src/rich_editor/js/mod.rs": "d18b31450952c89e0447a0835669f1965d58e853aec5136412ce2a45de8689da", + "third-party/nsh/crates/nsh-base/src/rich_editor/js/plugins.rs": "8bd91e1a4e961ebe055da7c46b3b9728b4192f025eef9125c97a2ef6f5aec509", "third-party/nsh/crates/nsh-base/src/rich_editor/js/tables.rs": "02a85ef766341d8a001950d26fa234323b34dd856ca8c2f2a0e0891767df0bee", "third-party/nsh/crates/nsh-base/src/rich_editor/mod.rs": "37857cd6859d96d3dd8a608cebc293702978e9016352da8ab69ae8a56ffdfad6", "third-party/nsh/crates/nsh-base/src/rich_editor/model/content.rs": "928593f1fc3917af3e4447345be46a96d2c5371617b2aa714cfe51abf225ad35", "third-party/nsh/crates/nsh-base/src/rich_editor/model/dom.rs": "7969492458ffc9ca1aba8efd2784b4d92ef1291565e103f7d2bf02e0cf559e8c", "third-party/nsh/crates/nsh-base/src/rich_editor/model/fragment.rs": "23410f869ad78d4d7506ef3b20b14028a2ec0169d9dc0baff30ccf36d739cdbe", - "third-party/nsh/crates/nsh-base/src/rich_editor/model/mark.rs": "68c57b790373bdfc11442763fc75f630a4b1f2a3e38e726a8dc3dc8abcea16ca", - "third-party/nsh/crates/nsh-base/src/rich_editor/model/mod.rs": "b9ba619a2590d68cc5c3090b23b1ff24c9ce949f5eb44beed9e13e6fab52aa62", + "third-party/nsh/crates/nsh-base/src/rich_editor/model/mark.rs": "1ee78f9498aeff7165c272a28910bcfd7725a089ca1f3394333f25378057c839", + "third-party/nsh/crates/nsh-base/src/rich_editor/model/mod.rs": "ff0b4f27f6374a93fd5de162256d14500cdca728222ccc8e1ce2e8bae46307c4", "third-party/nsh/crates/nsh-base/src/rich_editor/model/node.rs": "de051ce552e2802c2eb48a22a1ac57771b945c9240c8f57494574ca35ae03b74", "third-party/nsh/crates/nsh-base/src/rich_editor/model/resolved.rs": "f3064c9c71c65021742c2504299f93f3ff57f7bd94057ebba39d4efe0752c726", - "third-party/nsh/crates/nsh-base/src/rich_editor/model/schema.rs": "3c2de0e989c7719e68d0e50c4f74755965a2d805578b8eaced66f2657528edb1", + "third-party/nsh/crates/nsh-base/src/rich_editor/model/schema.rs": "e9c064978a320fa20ce4a00601740cbf30f46592ff938fc465c8f1f742c12bda", "third-party/nsh/crates/nsh-base/src/rich_editor/model/slice.rs": "63999feec5beb86a5c919a3c36a94bfde9d7c4430caf7ef3f3eebfb9ede64ef3", "third-party/nsh/crates/nsh-base/src/rich_editor/model/test_schema.rs": "013f2cad3094685769976f75834fc0c8a7cc20b6cbcd7deb1336e01adb7968bd", "third-party/nsh/crates/nsh-base/src/rich_editor/model/to_dom.rs": "3efaf5a7e4a358802941d6f4730aac577280ba6939cbaf37ea0ce5c9ff8c226f", - "third-party/nsh/crates/nsh-base/src/rich_editor/state/decoration.rs": "df7895bb2bf46945297ea4b44e8b478df73ff8528c03f2609f98820f6b593aa6", + "third-party/nsh/crates/nsh-base/src/rich_editor/state/decoration.rs": "73022e561a5663e9e1edadbef494d4d316fc97afa1cf1849b41242fd27a1baac", "third-party/nsh/crates/nsh-base/src/rich_editor/state/editor_state.rs": "85aaf4fa88e18bbbf79fb726ef1c383d2cce7a84c81fce8937416150da844ec4", "third-party/nsh/crates/nsh-base/src/rich_editor/state/mod.rs": "4c2aa6176efcc15a3c6a5ab9efe037367c568b25204462a5af82968af92a9e80", "third-party/nsh/crates/nsh-base/src/rich_editor/state/plugin.rs": "03a3e1c7ef039dec21c1952de0fdcdc036f75061378699496ff8326c262afda5", "third-party/nsh/crates/nsh-base/src/rich_editor/state/selection.rs": "e776e98bfa6f50c240f44ff05d5b4dc8e6ac0020ad7d5c5fb77d9173eb6f79b0", "third-party/nsh/crates/nsh-base/src/rich_editor/state/transaction.rs": "777fe2fafc1241b05c6707eb1109a9954f3f2b778980c5dbb2b138cf546eaf35", "third-party/nsh/crates/nsh-base/src/rich_editor/tables/cell_selection.rs": "4cf8b8a3bb5a0d15d52ac2ec328c7e5aff3a66596ce811ccf7ddc05d8274eb72", - "third-party/nsh/crates/nsh-base/src/rich_editor/tables/commands.rs": "759cf4a63e68ff802f16d952c433fdcada22fb5e9d589ded72f86358dad2cb49", + "third-party/nsh/crates/nsh-base/src/rich_editor/tables/commands.rs": "f4a887acf8f1a8bc855f18b0ade2e4d450b44128d35ce125dd92983d5f2d0e80", "third-party/nsh/crates/nsh-base/src/rich_editor/tables/editing.rs": "a4688309860db06581df7b92f4c6f9f5bb2a5db39ad46bd478b06a2097ab15de", "third-party/nsh/crates/nsh-base/src/rich_editor/tables/fix.rs": "2bd17eb5f95d2799aba430d39008b13be85094bad2bd4903dbd15ca98e6b4009", - "third-party/nsh/crates/nsh-base/src/rich_editor/tables/map.rs": "cca9f85133b038483636eb8ab6944505d22834e294389781556f71c676918100", + "third-party/nsh/crates/nsh-base/src/rich_editor/tables/map.rs": "9eca73110a2adc0eab5e5ee534135568bf05b9110946a2626026a5e35754b7dc", "third-party/nsh/crates/nsh-base/src/rich_editor/tables/mod.rs": "84539523cd513d91a8402fb5d93b4ea38b1ba887c525310d0104bbe26e01c5a3", "third-party/nsh/crates/nsh-base/src/rich_editor/tables/nodes.rs": "5005f2e15376de4948ad1c046a41cfc517b0fba25d306fb121347e9146afb4bc", "third-party/nsh/crates/nsh-base/src/rich_editor/tables/util.rs": "eed6baf2d116699c44703e0c840f90530bf0aeedc32c83896ad886a62340d7e7", @@ -771,25 +787,26 @@ "third-party/nsh/crates/nsh-base/src/rich_editor/transform/step.rs": "41039ad30f581b0b02f5cb78782950324268af147baefe53e40f7467876aa651", "third-party/nsh/crates/nsh-base/src/rich_editor/transform/structure.rs": "58069f0ef750399a3afbf3cbc1cba108e57ccdcb41505dc5d2330c09e7cf1e08", "third-party/nsh/crates/nsh-base/src/rich_editor/transform/transform.rs": "ed9b53bd4dcd1f775c89893c39c9a0e2ab3ffd264e69c1649bc76b4be6ff2159", - "third-party/nsh/crates/nsh-base/src/rich_editor/view/controller.rs": "2611c6234ad1d4d0b5f70361a17195a13f16b9fdfcb9d75144935f24bb47aaa2", - "third-party/nsh/crates/nsh-base/src/rich_editor/view/geometry.rs": "18e7457d539bd390b35987b52cbe68a326aa0dd247d25716dd43a3131ea00df7", + "third-party/nsh/crates/nsh-base/src/rich_editor/view/controller.rs": "1ff38e4b9236288497f8b89ac1bcb9a4a4f181c9bf391717b6cf9d5826aa49bb", + "third-party/nsh/crates/nsh-base/src/rich_editor/view/geometry.rs": "c9dd16f8290b82665f0f1c5f3b8133e61082f786b4761d68f3aee943788ef631", "third-party/nsh/crates/nsh-base/src/rich_editor/view/geometry_handle.rs": "72494917693f77a14b744bd3e1809749a3c537e2151af9f1adac54aa753b9d50", "third-party/nsh/crates/nsh-base/src/rich_editor/view/keys.rs": "8521028ae9b42960b5e6e232d6531ff33395e0bf5530c921062ebaeae089def7", - "third-party/nsh/crates/nsh-base/src/rich_editor/view/mod.rs": "83adfdf4b97deeb5783fbd53866b672723d50737437801db29141fca153ce83a", + "third-party/nsh/crates/nsh-base/src/rich_editor/view/mod.rs": "19e2c2fc1c9d51f043ce0243e4aea34024222601b36a20a0a30c8c2e29c4ba4a", "third-party/nsh/crates/nsh-base/src/rich_editor/view/node_view.rs": "41117655d137a88e25503d989633bc001f311bb6ba0f0ca546543cbec01611d4", - "third-party/nsh/crates/nsh-base/src/rich_editor/view/parts.rs": "94c4653b6833d30e183faf5d7fa042a331467e033f30b3e9ef173a0ca8143b43", - "third-party/nsh/crates/nsh-base/src/rich_editor/view/render.rs": "f2aeb8bf8a4ed430eb13b5d34eed25f28f5c813f1719ad28afadfdb398ad7b36", + "third-party/nsh/crates/nsh-base/src/rich_editor/view/parts.rs": "69f08141f387e6df7ae2bc24adf6d359806b186bb9034df47a88ccee12305bd9", + "third-party/nsh/crates/nsh-base/src/rich_editor/view/render.rs": "7ea39d9ebb3203cae5404795cb1bb683e352b9c2c11616ef950da4adb9956c5c", "third-party/nsh/crates/nsh-base/src/rich_editor/view/session.rs": "23472fd133e795f38efc7c6c6f53c8f07ddd99f8d3b58c3b44d055c4e7116159", - "third-party/nsh/crates/nsh-base/src/rich_editor/view/table.rs": "8cf3859e68aecc3597e24a582f635053df9fa83304aad0bfca5d423dc2df3a5d", - "third-party/nsh/crates/nsh-base/src/roving.rs": "62a9d55376dbea2094447ac7298785ba673974518b6b57fa875b5f4cbd511cdd", - "third-party/nsh/crates/nsh-base/src/scrollbar.rs": "5ab5543880f6c5870c38466682dc39760f55c397ed3ff0362f2513ac7839af81", - "third-party/nsh/crates/nsh-base/src/select.rs": "68477bb24510c8fbbd293c43b503be1303fc2c3901f708ba68d92aac62920f67", - "third-party/nsh/crates/nsh-base/src/sheet.rs": "418acb201d239df3bda09ac1ae1af226dbda9f884dc904b7d180d384ee4514ff", - "third-party/nsh/crates/nsh-base/src/slider.rs": "44e21e008cb090b186ab7077aad2518ba2063b72d8ade104f207f79cba7704f6", + "third-party/nsh/crates/nsh-base/src/rich_editor/view/table.rs": "20b890b86cab3ad27374b386d68061e083b4c668b49618ac202922bf0f6323e9", + "third-party/nsh/crates/nsh-base/src/roving.rs": "a7dc1cb97feff820473ad313ab3411c3d9c6b1c847aad93559129dfaeb5b944f", + "third-party/nsh/crates/nsh-base/src/scrollbar.rs": "82a026bc2401fd22c67a45ecaffb0f4a85a7d797308ff0188507e4f95f0a93de", + "third-party/nsh/crates/nsh-base/src/select.rs": "671eed7555c7f677ae62fca2e4c509a5284432a148b43e372305372f7e317a08", + "third-party/nsh/crates/nsh-base/src/sheet.rs": "f49e77c19c20bfb73c2bfb9bc76448e47ea182e36293feaeff16c8f0c651907d", + "third-party/nsh/crates/nsh-base/src/slider.rs": "9f5c7acbb5f419e3d2e636bf07a46d82e72c7c2121fe249bf5cff10500e04f6f", + "third-party/nsh/crates/nsh-base/src/sort.rs": "423e5984ca94bfa04b66e7b724f55f0a7d9495aa38e04ffcb872000dc698738f", "third-party/nsh/crates/nsh-base/src/switch.rs": "1199b1453aada43fa335bf40c80bb70ef41987b39981b6ffa55fee78c69f6863", "third-party/nsh/crates/nsh-base/src/syntax.rs": "ae23a0b930868d1aaabe481f9e0f9a04d8344228c09a6d638a29a8104f0d0f84", "third-party/nsh/crates/nsh-base/src/table.rs": "9680cb5afed8a59f7c6da62e15102ef1af4bf6328ac17952d3c1407adf77b8a0", - "third-party/nsh/crates/nsh-base/src/tabs.rs": "eb2fc6d052993076aba8730848f66bc9d1e08be9824e276e91db6d9ab754f3d9", + "third-party/nsh/crates/nsh-base/src/tabs.rs": "264618ef007835b36ce67e2f1dd1d73040598b6fe91c6e64796793bfefe8846d", "third-party/nsh/crates/nsh-base/src/text_view/common.rs": "46e22cb951fe4f64fd1b71b56bbc8dfdba6899a20532f4fabf9e5d0dc470e0ed", "third-party/nsh/crates/nsh-base/src/text_view/document.rs": "d63c9a1836bb514d5eff111ed511be8bec6c579dafd00ea1d762deee55782aeb", "third-party/nsh/crates/nsh-base/src/text_view/extension.rs": "44ed8ffd2e9d711eb4c8fec88990ed833cee8694b88bc8d4527f4c8fdfa74428", @@ -808,57 +825,73 @@ "third-party/nsh/crates/nsh-base/src/text_view/parts.rs": "b58369b29c8dddd40fca7a23cc5ad913b147b7ae8198db9d0b906a62559a3345", "third-party/nsh/crates/nsh-base/src/text_view/view.rs": "9d60bb6a68f08ed9933331f48002e26388a5c69f6f4d040bc870543ee94d93c3", "third-party/nsh/crates/nsh-base/src/theme.rs": "707db0ae53fab28f2f6b124b8091e8d975f85617e985ed3ffce2ae34e6d3002b", + "third-party/nsh/crates/nsh-base/src/time_field.rs": "1e6ad4d5f7574e74f1583f66c659d1af983fbc25f90de14b5fa477c2b32a212d", "third-party/nsh/crates/nsh-base/src/toast/js.rs": "752efcec9c5ba32102ef18c9d8b6edca1f71e14e32cef83d4c99b2c831d4c1bf", "third-party/nsh/crates/nsh-base/src/toast/manager.rs": "5f5b5a03a7a25856b8b98d7304bf4b0efa3fad702240170fbb2b89be72ec2351", "third-party/nsh/crates/nsh-base/src/toast/mod.rs": "54493a56939e48a607e2d188ebf61960709c3fa2b85a7a680a6e00bcc1e0106b", - "third-party/nsh/crates/nsh-base/src/toast/root.rs": "1832d68b17083badcb670333027a8a265b02dd6c79cc326f2c3cd7e38fdd2a37", - "third-party/nsh/crates/nsh-base/src/toast/stack.rs": "c419e1cb46e274b16a5ca1c5388ee682562411de31b456cdcfb66dbadbf2aff5", - "third-party/nsh/crates/nsh-base/src/tooltip.rs": "8f1ab180f192c6767758dfbbaf21e7880e860463a6dadcaf0b2795f27b625a2b", - "third-party/nsh/crates/nsh-base/src/window.rs": "a3f86e000659bb0cc11dfe57f47b023c2081803bfe449644ee712ceac3aecc6f", + "third-party/nsh/crates/nsh-base/src/toast/root.rs": "ec01f8f5bdea959574775bcc7cf1f05539ac71b63e52bb133d2671e351d906e8", + "third-party/nsh/crates/nsh-base/src/toast/stack.rs": "18df181e20366206c35b5d60f55604471072bd9c4f874269294f8c56c4a31ff6", + "third-party/nsh/crates/nsh-base/src/toggle.rs": "ae712af9d01c75434d9b0e71e6adbee7d76cf79b04bc43f404df3f722f1b8860", + "third-party/nsh/crates/nsh-base/src/toolbar.rs": "26249c83246bd1837b2ddb371bdbd349558beab97135e96ff7a28c70f7957112", + "third-party/nsh/crates/nsh-base/src/tooltip.rs": "0955baad09958fd7414d7940e426f67311c1e5b476cd8494b57de39c1347e7c8", + "third-party/nsh/crates/nsh-base/src/window.rs": "179f1cca79f589602d7581fb951ad45efee6280170f5b428ed2fe60b9f205c77", + "third-party/nsh/crates/nsh-base/tests/base/accordion.rs": "62a7ed7c0062934ade19c8a1ab274e7e95c0c0aa55c228093e695e6118a5f960", + "third-party/nsh/crates/nsh-base/tests/base/avatar.rs": "368ff1e6710edd42f119244654481d8b3ba9f75d6ede5cebf52090d05339a19e", + "third-party/nsh/crates/nsh-base/tests/base/calendar.rs": "bd94f90c011d3cad8050966c4e0b99af4e7a393ecd094f9063a3bc181cfcb4c3", + "third-party/nsh/crates/nsh-base/tests/base/collapsible.rs": "cac40bf273c53ca71f9d620926af975082848d3b9d7d7fcedd1fd5f3fcb1325c", + "third-party/nsh/crates/nsh-base/tests/base/color_picker.rs": "b72cad2ad66557653a6ecea71c01226f02f2e473f9db32ce053a204b0938070a", "third-party/nsh/crates/nsh-base/tests/base/combobox.rs": "1d71818b1da19a4de4afdad28465c83a5a90a8ce8084f8f453ce843072af9ed0", + "third-party/nsh/crates/nsh-base/tests/base/date_picker.rs": "98d6b0b0a7ec82348df4bc5fb01bdae1db4fff8b106e8b120a8cb181cbef2be1", "third-party/nsh/crates/nsh-base/tests/base/diff.rs": "f2de8cb6a447ea40876b912a20a22d6501f2772a8f21dc2bf95c42520cfe04f7", - "third-party/nsh/crates/nsh-base/tests/base/dock.rs": "16a8871b9821494606e9101b412dd65ad47a55a5294ed53da90518a61ea16d63", - "third-party/nsh/crates/nsh-base/tests/base/editor.rs": "63347f8188078e2cef68f821b5815468f93961523addf6be708f850fabaebc45", + "third-party/nsh/crates/nsh-base/tests/base/dock.rs": "77484c7238c462b5381dfc9a73396dad22685300f16c3db514a0ce7b6a5bb27f", + "third-party/nsh/crates/nsh-base/tests/base/editor.rs": "ca32c4422f056791af74be28c9636dd66b1195fedeeccc86c967e12b1621c1f2", "third-party/nsh/crates/nsh-base/tests/base/editor_completion.rs": "454dad7338b7bfcffe7a99ae431cdd18d3782b14ca43e3e83f43c5742bbed8d8", "third-party/nsh/crates/nsh-base/tests/base/editor_definition.rs": "c78fa853e5468b198ce7286b3dfe706c536cf3179cb65c726972727af3d41c99", "third-party/nsh/crates/nsh-base/tests/base/editor_diagnostics.rs": "c84b4e030ec9421063c494b2bbfd194e2c7460ac66a54b64044ce4c6603a246d", "third-party/nsh/crates/nsh-base/tests/base/editor_languages.rs": "814031df28c7287fefc554d5175fd495571c6f21daa80ca105e922b396ba2b6e", - "third-party/nsh/crates/nsh-base/tests/base/main.rs": "86c03746867db0e9e411519245b54b787b3097033cacfddae88648e7af288610", + "third-party/nsh/crates/nsh-base/tests/base/link.rs": "e0e3ec0e3e658ce7a09bc3cc886800a5c880ab8a8bac4e3f00ad9b0ec2a57749", + "third-party/nsh/crates/nsh-base/tests/base/main.rs": "a62d506b26867f3fcbbf7775fe8701fed362cf5267b602a06247e21b6919d79b", + "third-party/nsh/crates/nsh-base/tests/base/menu.rs": "d593d4b11398a67e86e549b97549c17d5dec8982d670b42f5569ffa5d7ecd74f", "third-party/nsh/crates/nsh-base/tests/base/number_input.rs": "0cf2d19283b8907e492bb6cefb12670e0cdd82bb580ebe96928cf4c86766c973", - "third-party/nsh/crates/nsh-base/tests/base/otp_input.rs": "f3846b6ed77919af4897470830b9a94d7864c3d62423cde126863595ce032778", - "third-party/nsh/crates/nsh-base/tests/base/plot.rs": "3f4210661d5b2db9f70c20d4c6142f1e5e4e1048cd7a096862a2b504fcf05bfd", - "third-party/nsh/crates/nsh-base/tests/base/radio.rs": "66e23543f1b1855f2a1febfb068235af58e996f328cfff007e1af44008cb746f", + "third-party/nsh/crates/nsh-base/tests/base/otp_input.rs": "6869b7c03e2fad5e8b0a302614cf646650f813f2ab0fc6e37f7b9956c4f7a205", + "third-party/nsh/crates/nsh-base/tests/base/pagination.rs": "3c91fa46ec2d6054e9ad04d94fd732ccac09409c1e67a60cf637790a2b40c9ab", + "third-party/nsh/crates/nsh-base/tests/base/plot.rs": "5e0f6e2cb1205e084ab3da2b34d2b4487f9d30b842569e3b88fc9ef04f6ca4d0", + "third-party/nsh/crates/nsh-base/tests/base/popups.rs": "c5e7d60807369e786a05b6db25b1dbdb51c2818f00c8a784bec56168be003ac3", + "third-party/nsh/crates/nsh-base/tests/base/radio.rs": "33d497837ea016a7a359ff429dc89072df919fa92c21ef1b9dca2682d257c215", "third-party/nsh/crates/nsh-base/tests/base/resizable.rs": "be0859e6ecea4921e802eabb81a73e870e881a4186be39dde88787243e22c820", "third-party/nsh/crates/nsh-base/tests/base/rich_editor/commands.rs": "0128831e9dc30a9ebd336791dad12a2dc6e15f69fa55b261faeffc35bc2ed84d", "third-party/nsh/crates/nsh-base/tests/base/rich_editor/extensions.rs": "2b9c47ced15a727e5bc14a0bdb480f58d41589bdf04a5c9049d0fdeac1abd2b0", "third-party/nsh/crates/nsh-base/tests/base/rich_editor/marks_clipboard_history.rs": "3460ac3451f05c053d88e4cab3c26b34e68d7e887347240fcce017d79fbc7503", - "third-party/nsh/crates/nsh-base/tests/base/rich_editor/mod.rs": "81f80d3e3d6b77c720d1843e041b66c8870563a029f5e6dac29c6ceebb10a2eb", + "third-party/nsh/crates/nsh-base/tests/base/rich_editor/mod.rs": "4c74f293c7df0783885df5ae3f9c4b330c04de65dd81cd12ccfb474bdc28a3f5", "third-party/nsh/crates/nsh-base/tests/base/rich_editor/selection.rs": "d6446aa838fd97defe07eec4060dc7f24665c879bffeb3753fd2efa2427e49d7", "third-party/nsh/crates/nsh-base/tests/base/rich_editor/structure.rs": "9c5585ca6473b972e980d059761ed9a40b46da1aa40d996c3c9aabd89361c86b", "third-party/nsh/crates/nsh-base/tests/base/rich_editor/tiptap.rs": "3e167e9796fc17657898dbc880665e67bf01ffb2919b702978cd599197116169", - "third-party/nsh/crates/nsh-base/tests/base/rich_editor/typing.rs": "87d8779ac13d860f635648b00825f12981509c3c62ed2a7ad1d6292b6d15537d", - "third-party/nsh/crates/nsh-base/tests/base/rich_editor/view_features.rs": "5dd0e9c1c4011dce4ed29914126b6bdfab4df082cff16761022aa861e0af251d", - "third-party/nsh/crates/nsh-base/tests/base/scrollbar.rs": "387215b08bb5ee2d23c35c89f4964b2bcb9490ff33cd095ed7923546e7f59936", - "third-party/nsh/crates/nsh-base/tests/base/select.rs": "1b7db50369af612265f271e267264c12f4b7a767657795b2c36322dae99160fd", - "third-party/nsh/crates/nsh-base/tests/base/sheet.rs": "193a1790d4a7d98131bcbd80d4ccc66d5e70193781661fc239c9a5f0640f42c8", - "third-party/nsh/crates/nsh-base/tests/base/slider.rs": "cc007f1b13546751efade478ee1aa7cbe127b5649277c20f71ee326345f5c721", - "third-party/nsh/crates/nsh-base/tests/base/tabs.rs": "b73886c2102a6d1a67129aeb3b4f750c9672bdd54fe92854face6e7cf4674cdd", + "third-party/nsh/crates/nsh-base/tests/base/rich_editor/typing.rs": "75313376b2f0cab9a2ec1c0b7dfd9121afcd47ff95684e63adc6146008105435", + "third-party/nsh/crates/nsh-base/tests/base/rich_editor/view_features.rs": "7fd99e0a11d6186dee60af2587516bfd945439c7785ec9261b98fdffff1601c6", + "third-party/nsh/crates/nsh-base/tests/base/scrollbar.rs": "420d88d2a2a866520ab19056b74700ee17ce1047d67b19a6c0194d5bc29d71a9", + "third-party/nsh/crates/nsh-base/tests/base/select.rs": "b9d6a61e5ef0fe85b0368185fb8f610c714c6ba0f359efb1000207175d3780f9", + "third-party/nsh/crates/nsh-base/tests/base/sheet.rs": "ab83156a196a1c6daebe0602fe3d373ee0a371a7a060ea6c5844bd813fbdf34e", + "third-party/nsh/crates/nsh-base/tests/base/slider.rs": "847ec65be637cedffafa23dfe009740f04502d51c4720260c5694a3df8f23f39", + "third-party/nsh/crates/nsh-base/tests/base/tabs.rs": "190e5309daea9f35d1ac5d57787bdb831bdebb402acdeae1419eea22f81c9142", "third-party/nsh/crates/nsh-base/tests/base/text_view.rs": "5c73ad03c56bbd0c64fbcefbfca0db4b1d81bd20db22b7a3a92731c5347376be", + "third-party/nsh/crates/nsh-base/tests/base/time_field.rs": "4cdd7af04322c83257693706ff9b20bd16641fd4d7dd08cb73ccd54be21a6a90", + "third-party/nsh/crates/nsh-base/tests/base/toggle.rs": "25fbfca16e07968c5f902099a5ef76619208d7f2e6dc61f7b87c15b21e291f9b", + "third-party/nsh/crates/nsh-base/tests/base/toolbar.rs": "4ba712d3b28365e90e513c10b95fba91b990b77d8d5ddd3d9bb0bfe21046c50c", "third-party/nsh/crates/nsh-bundler/Cargo.toml": "9515c8670b09613436d7f677ce58781f2664fd1747f043746e78a98b380f694d", - "third-party/nsh/crates/nsh-bundler/src/bundle.rs": "1bdb8a15034696d49a47895277d7532d0df459fbf356b040ac2e6cf6de6584fd", + "third-party/nsh/crates/nsh-bundler/src/bundle.rs": "f1773d117924f249812f6fc90f89b44f725e818235b458f9216b6a5fa3de2343", "third-party/nsh/crates/nsh-bundler/src/classes.rs": "b6fc4affe5ca9ee0dc38b0b00579375dd2c6d6684424e4b6aa08371ab965fb40", - "third-party/nsh/crates/nsh-bundler/src/compile.rs": "6c0973539b7e4bb576d32c391dc981bd4e247fea28aba678207eab3740ed7344", + "third-party/nsh/crates/nsh-bundler/src/compile.rs": "dd96528f6358cbfdd7e2fd5f66ad84b2ace93faf36e61b1f604cf6ffec83c3cf", "third-party/nsh/crates/nsh-bundler/src/hot.rs": "c3655c9dc2c1c561831bf33d0a7915bfa596063ac130cca4b79e37a021d76c10", "third-party/nsh/crates/nsh-bundler/src/jsx.rs": "cc22781e61adb99bf953399e5ce5ee475054ae898de18c9ebc0708d1c51bd1af", "third-party/nsh/crates/nsh-bundler/src/lib.rs": "d574a0678c35c66155e71c97cec472c8308ca3d6b79bb0da07436e85e0eb8020", "third-party/nsh/crates/nsh-bundler/src/minify.rs": "09bb43220c92c72daf59f73b7a1fc3c950c40fd32bf872ad93967ed7cf745af3", - "third-party/nsh/crates/nsh-bundler/src/natives.rs": "8e99802f394030899d21c20f931c94a1a87c9678d83c18b0dd161213ffc8a764", - "third-party/nsh/crates/nsh-bundler/src/plugin.rs": "476bda4eef896440c23e2c86c1b9f8b0259f7a60be52d715c8c6d3f3d2d64264", + "third-party/nsh/crates/nsh-bundler/src/natives.rs": "d5327180c2bd55c88fda70fc5dd2c1e6ba782171fe9eebf2ce88e7ff7594c53d", + "third-party/nsh/crates/nsh-bundler/src/plugin.rs": "96f2b87104111a9cfeea2e7e971a978877212928301db8b0958c05d7153c59e4", "third-party/nsh/crates/nsh-bundler/src/props.rs": "b7b2122af74037f63ae74e2c4f5092d5ae5138aa78cd81abc03d9c8d7f7fa337", "third-party/nsh/crates/nsh-bundler/src/split.rs": "dd958b4d93476155b5cb557389e53673b89c1c9c525d74c8126367a95529024b", - "third-party/nsh/crates/nsh-bundler/src/standard_tests.rs": "88f73548512850cfa4846d58ddbe675f1adbc674e0e1f8c45a4e86bd267cd50b", + "third-party/nsh/crates/nsh-bundler/src/standard_tests.rs": "7ccb656973f3a8176d0d7672b5807a4439fdfb8a0702e4ce7c6324870507c437", "third-party/nsh/crates/nsh-bundler/src/types.rs": "6a8f272d9fa7a01734f783f6cc02bd238692008889a6abf64102f37407c3ab38", - "third-party/nsh/crates/nsh-cli/Cargo.toml": "afa36bc0671c53669ad41e6ae22c396efee8d798b1ea35f381b2effc8e6bb4ea", + "third-party/nsh/crates/nsh-cli/Cargo.toml": "683f133bd2aff174a30236d938acce7c0d4706f7f3350969b74eedfbcc0bcacd", "third-party/nsh/crates/nsh-cli/build.rs": "617f5d3890c8b278c74b7a4620a148a0a8079fa7dba2e98b0726cac5d0fef494", "third-party/nsh/crates/nsh-cli/src/android.rs": "328e5ffcd4709f16889bb210e118cf1e6745f56b3421f2dadf337a7ef03cdf16", "third-party/nsh/crates/nsh-cli/src/bindgen.rs": "42984cca78db366fe087e2a86aaf291ef4d4a0220cac26b17ae99ace2905da8e", @@ -872,10 +905,10 @@ "third-party/nsh/crates/nsh-cli/src/init.rs": "df7a5d4688498eb7921c23d2bb76b5d752ddf7450b3d58e7f1b3b62a1f784eb2", "third-party/nsh/crates/nsh-cli/src/logger.rs": "09574fa1381d8dc650d820f6953541601180698657cf3eda2f1429165122840d", "third-party/nsh/crates/nsh-cli/src/main.rs": "735cc346a8c5b44437404681aea4c8c71a87af3cf0ddd8d028b39ea6d84b1169", - "third-party/nsh/crates/nsh-cli/src/package.rs": "b854c555f5f1b54c149f99aec78547b0258441ecef65bf2b449225e42c508aad", + "third-party/nsh/crates/nsh-cli/src/package.rs": "e357a69294bba0983d451bc1e417a007316667270a889210364cf7bff34a7a36", "third-party/nsh/crates/nsh-cli/src/run.rs": "306478e803971364c33ce566524c537811c4f201c70eda8ee2f84ef0d11ac2d2", - "third-party/nsh/crates/nsh-cli/src/runtime.rs": "27de787910c042da8627ff691490ea740bddedff6dbd3099d83188c7d0220414", - "third-party/nsh/crates/nsh-cli/src/rust_app.rs": "aece5705bd173935f16fe372232182f0fde2e815b8f75b7bde40df0992148fa4", + "third-party/nsh/crates/nsh-cli/src/runtime.rs": "6dd942446d7e949a091fc04935afb4b9ef67a96bf8c6a3f5ed9ff072642240b4", + "third-party/nsh/crates/nsh-cli/src/rust_app.rs": "0a1a6efad904513595e4e9ae491dafee8ef998d46a80d03d64055cf31b37b344", "third-party/nsh/crates/nsh-cli/src/sign.rs": "fc514e034f23cc868166368b7add5a8f8ead0437bddbdb61e6296fa7ecf55028", "third-party/nsh/crates/nsh-cli/src/templates/mixed/App.test.tsx": "8bfdffa4cc29537e750cb3c281fb815bcee41e457ab70cebd3cf18ae2a1bf40e", "third-party/nsh/crates/nsh-cli/src/templates/mixed/App.tsx": "19b9ecc1f2fbf9c8e9bf4bb974def5810882c3a8c93c54bdd0305a51e094bc4f", @@ -890,97 +923,102 @@ "third-party/nsh/crates/nsh-cli/src/templates/tsconfig/3.json": "832b63d1c87c6381f7d49d7f55394ce4ed2503d3cf0d0124b81a88ec9621a330", "third-party/nsh/crates/nsh-cli/src/templates/tsconfig/4.json": "d7972c656ce45ce90df97fd5370c26c660c5fe8fca5355cf24822498039685ee", "third-party/nsh/crates/nsh-cli/src/templates/tsconfig/5.json": "5d6ee953f0366848a5cbe9fe9d0a6b31637d14e8dfb20761e7730c9228753b1a", - "third-party/nsh/crates/nsh-cli/src/test.rs": "ae6369f2a8096fd829f45d65f2f8d5eec0aae720ac5a4bc0eec952ff171cf9f7", + "third-party/nsh/crates/nsh-cli/src/test.rs": "46b59fb772a08b1aea6aa46a94a1468f4dfe1231352a7cbbd611d22b6a64dfe0", "third-party/nsh/crates/nsh-cli/src/test_android.rs": "6f3ca5c6caf8e46d48fa7d3046e82a9d1d5f50efd1a6a68a08f3c0472295c48c", - "third-party/nsh/crates/nsh-cli/src/test_host.rs": "ac202e7909d8873aa51276f0f42ed99f673ffc31e8fb96a9950b4a2200a641e9", + "third-party/nsh/crates/nsh-cli/src/test_host.rs": "8ca3cc5d23d12ba63db4edc5293d92244b5e50a37c904d7815fc06e9df731661", "third-party/nsh/crates/nsh-cli/src/test_ios.rs": "001f75d6f862a31d2352e09c51317f100ae6ddab8d983761315c25760b258f01", - "third-party/nsh/crates/nsh-cli/src/test_web.rs": "605edc85b6ae063f73a79cca3d45d74ccba7b7dca1de31c473ff4409abbff96a", + "third-party/nsh/crates/nsh-cli/src/test_web.rs": "b657afd25fd7f33f734cd8ac597bb580a0f341a0453828b1d73f3b71b74436bd", "third-party/nsh/crates/nsh-cli/src/tui.rs": "edadbdd4996dce76cf87b4e8eb4e4101fa6ed338f2ebb7f0725a6c61d2bde6c0", "third-party/nsh/crates/nsh-cli/src/ui.rs": "04348eb8eec090d36e443ae1a0cfddda00c71b80636c7a0b541951e32409bf6d", - "third-party/nsh/crates/nsh-cli/src/web.rs": "4fc0274fb4f42da1742e9747f92357147b1efacb8873c883768ee149ffacd111", - "third-party/nsh/crates/nsh-cli/src/web/dev.html": "e4ff740d6776686ce3fea4f9520f4e811b4adfdd04fe76dbbf3ed167fbf31f06", - "third-party/nsh/crates/nsh-cli/src/web/index.html": "02f001079aadcf64d2bd7e68aa6c9cad8bb1fb5bd6ba583c67ad8d5d0b4ba62f", + "third-party/nsh/crates/nsh-cli/src/web.rs": "35758f904f84c071b6250e790c4283b5937b745a0364723b9845425687f98bfc", + "third-party/nsh/crates/nsh-cli/src/web/dev.html": "f35f42e6876ae6f6539511464aef427e9de77a276bd366a85414c14d05a5ff70", + "third-party/nsh/crates/nsh-cli/src/web/index.html": "22ea35ffda49ca9dba63258e55565db55cea88cfab60196f60880716ec24e0e7", + "third-party/nsh/crates/nsh-cli/src/web/prune.rs": "beb3c5bf9adc5c8ba40e339a587d8143e55f47e41f7bb638fed69584bf78227a", "third-party/nsh/crates/nsh-cli/src/windows.rs": "81b1e8af3c91b3d45f7c0d05ac2abef4ca426403e30f7c71c8029d74bfde6b24", - "third-party/nsh/crates/nsh-core/Cargo.toml": "509b46f793fedb36b22a735931273908281b22566a28831a2c810c3f5bec82df", - "third-party/nsh/crates/nsh-core/src/access.rs": "a85ee4367eb53c22113b4d0fc1f6fd581591c5b1f3a2290634024f82313dba88", - "third-party/nsh/crates/nsh-core/src/animation/animator.rs": "5fc4d4cb1949b2d20609abd886f56221fd756b14c99d860a805337c63ef18114", + "third-party/nsh/crates/nsh-core/Cargo.toml": "de2800a7ab8c0a0544cc1ca79eaae9fe691d0c4607663103522b7cf2b45a3203", + "third-party/nsh/crates/nsh-core/src/access.rs": "13b9f652681441f9f456b122fab1d10188bd7abec263b0915640182e1005ddf4", + "third-party/nsh/crates/nsh-core/src/animation/animator.rs": "7fe6a17413edea1aa5e5cd5cc95b70e213d10a40cb84e2ffa50f1510a90fcc0b", "third-party/nsh/crates/nsh-core/src/animation/driver.rs": "cf3d2b4e7d5900193d0d9aad828cae54191090452cedc9dcf2a3aed73a9c0d48", - "third-party/nsh/crates/nsh-core/src/animation/graph.rs": "9e0545b7a0c6b09cbe27f14486470e78fddd03e1708def1a1616086ab4d638fa", + "third-party/nsh/crates/nsh-core/src/animation/graph.rs": "778bfd3a50eb16a923c64cce62890c738ad04c3e1085da979ce173c5dbd95383", "third-party/nsh/crates/nsh-core/src/animation/mod.rs": "ab05697e579dafcc829c37b16f9207deeaa4482b5ae2777cf5cd31ee24b67031", "third-party/nsh/crates/nsh-core/src/animation/motion.rs": "d7d26626aaa2cad06a4d2b5bc365cd24e7e1e9706e181b0ad550c6388d3d9fea", "third-party/nsh/crates/nsh-core/src/animation/ops.rs": "3d33e05fb91b683235fd8ab17ac64a0b72415b4da808d1305f1ca0ef0d8b981a", "third-party/nsh/crates/nsh-core/src/byte_size.rs": "9cbaa27c69a16eadbc40b898a0ed683362a7c1c1bce39b7f2fabf8b26e46db43", - "third-party/nsh/crates/nsh-core/src/canvas.rs": "24e4c8f6ee955b1a4bef731fd4613ba6c9fd1a490260d1c705a715f94ec9df36", + "third-party/nsh/crates/nsh-core/src/canvas.rs": "72d71fd665c2fdadf3877d40a2f00763e9def862c439ad7777ad8d45c6f54694", "third-party/nsh/crates/nsh-core/src/canvas/damage.rs": "ba9be945fd73be587a4a3262599dbfc1a4fba00c1fa0844cf5fca8633b8f6556", "third-party/nsh/crates/nsh-core/src/canvas/hit.rs": "2e3528e7f911e5d4ab735b19e8f79a54a037748598edc88dd9309195106b8e36", "third-party/nsh/crates/nsh-core/src/clipboard.rs": "36b1c87f8762288ba9f8282daa03a5deb5541769afe75d58a7ba0cf8fe7defb1", "third-party/nsh/crates/nsh-core/src/color.rs": "93afe9bf38e8f7b12108fedc4863f8e1bee3a55ae7d0c75e788b79b02fb72a93", - "third-party/nsh/crates/nsh-core/src/core/classes.rs": "16414baf209ea859171e9372206cfc0daa291842f9ea0b37bb5f25b2687ada3e", - "third-party/nsh/crates/nsh-core/src/core/components.rs": "ea5296b9868c4eb13d552974853cf6eb07372fdf941e1fcdee21655aac4c52d0", - "third-party/nsh/crates/nsh-core/src/core/layout_tree.rs": "d28e3c5657e308c9cc254529457a025b057735d743e529bcf5e11734b8b91109", - "third-party/nsh/crates/nsh-core/src/core/list.rs": "22610b1cec4a857ba66d250a23635d0777bc54e2e6316cc2cc4bda5be9a807ee", + "third-party/nsh/crates/nsh-core/src/core/classes.rs": "fa7898188dbd167d57c27ff6e3aa85e1a3d04d1910db803165612ba3fc7e138e", + "third-party/nsh/crates/nsh-core/src/core/components.rs": "59e8fb56d5d15590c134406dd0fed9b2bc2f0932bb7ded0547cdf567441c420f", + "third-party/nsh/crates/nsh-core/src/core/follow.rs": "933d2efd574d4a72f4861799d7ad08b980d8f7f4a81a11cb4df83ebf41868753", + "third-party/nsh/crates/nsh-core/src/core/layout_scope.rs": "47b65f93760eb6123f2b8775072258c4a1f41d0b37d206140e0f19e439802668", + "third-party/nsh/crates/nsh-core/src/core/layout_tree.rs": "7c815c4e27d6c463dfb93399ba8e36740e4fc6591a070105fee8cc02fb35a28b", + "third-party/nsh/crates/nsh-core/src/core/list.rs": "3e63ade0d1066f82586d563e8c87fb3d539c74591358670f7a3a52371fe4c0c9", "third-party/nsh/crates/nsh-core/src/core/measure.rs": "aa766c86618cec531fb8e9d1f1c4061d0c6284abf3c40abfa011e2773220ec08", - "third-party/nsh/crates/nsh-core/src/core/mod.rs": "eba11f15a57bf2b93528a270b238e372c416fd5e51b547d96ee59f07549ea1de", - "third-party/nsh/crates/nsh-core/src/core/overlay.rs": "fbb2936058416b1ee69baf939b56a3f4d25ece6b6883ec4ed19c050ae80665e2", + "third-party/nsh/crates/nsh-core/src/core/mod.rs": "4102d1fd736c567cccb682a98031955932ff167f1c588122fd7d11d23f62fa97", + "third-party/nsh/crates/nsh-core/src/core/overlay.rs": "a731c4319e2336e17b63aec69b0135899b058bcdf484cd7550fc300dcddeab0d", "third-party/nsh/crates/nsh-core/src/core/parent_viewport.rs": "27752798f6a954732928829fdcc5cd56e773655ddc37fc9c0017ff566c7f3bba", "third-party/nsh/crates/nsh-core/src/core/reading.rs": "dd2799169ab691282ef6d86e40677d8edd2e0495ffaeb35c2b3fee7ebd782825", "third-party/nsh/crates/nsh-core/src/core/renders.rs": "eb3bec7a6d0becc269232548f1f10337a4a0388f7b53cec9bd85224a5b0ca5d8", "third-party/nsh/crates/nsh-core/src/core/scrollbars.rs": "6340140059e8d1b91e34e0d2d7dc480cb6bb6060ff6f88947db68aa8e03fe2ed", "third-party/nsh/crates/nsh-core/src/core/services.rs": "2dffe1959dbeef1a9e871c2281d07d95fb9b709b268b97ad2ba588d3280723b5", - "third-party/nsh/crates/nsh-core/src/core/slots.rs": "242cdd6c40bf8852f851f5dae1cffb528523df9d7ecbfd82daabf15ccacc1a91", + "third-party/nsh/crates/nsh-core/src/core/slots.rs": "d46d961e769f53a29a911311c8a124259c3585c9b58279261361a8dfd84847cf", "third-party/nsh/crates/nsh-core/src/core/sticky.rs": "18ed4ac18949de18b15270d8e55b272b8f8d8aa1a618b762d17d051b49c05eba", - "third-party/nsh/crates/nsh-core/src/core/tests.rs": "a5b4aa8458f08a993c3b9029fc858199afd81985bdbd319145523c65380bf35b", + "third-party/nsh/crates/nsh-core/src/core/tests.rs": "a0c70a9118e4bc1f815bec2e98d8573537c9a996f306e1a5a4817fe26f43421c", "third-party/nsh/crates/nsh-core/src/core/touch.rs": "b408dc05ea6e2ddb177b8bf225ac773e04d37b4a4ccf55de8001f07e91eee55a", - "third-party/nsh/crates/nsh-core/src/core/views.rs": "c2d5b1d60849357a4c3e38203849b1cde400dd33baa61b3201e952fcf96b5535", - "third-party/nsh/crates/nsh-core/src/core/watch.rs": "b136fe5594bb7226df1e1b636a72b3983fbfeeff835a70b4fc0ae1efbf8c031b", + "third-party/nsh/crates/nsh-core/src/core/views.rs": "b3d15fe6735e5aadd4fa4457a8b6c4dfdb16f5a3acd3f69d8bb53b57d91a3080", + "third-party/nsh/crates/nsh-core/src/core/watch.rs": "432201b5142e862fa2201d7a9610a299d72878b3131da3be802a39770a019c13", "third-party/nsh/crates/nsh-core/src/css.rs": "d5e87ee2380c21ad43c69cbbc2f7631e3a330ce5953e631e03801ae66ba1a253", "third-party/nsh/crates/nsh-core/src/css_named.rs": "99459b7095e5729120419fff9e4c30795a660c743978a11010777f9a31a13b39", "third-party/nsh/crates/nsh-core/src/default-env": "dd2723803c946daa91ceda4cca488b2850629f7655a5b1515ceb095742a29a9c", - "third-party/nsh/crates/nsh-core/src/desktop.rs": "a9049b6b05a59ef68b4683ea32e478a2dd49a5e01c7680fb1548ca3538371a3a", + "third-party/nsh/crates/nsh-core/src/desktop.rs": "8657d00f270192f82694c49ee5d2afadaaed81e20376a513f4b3c97d00f6ebe3", "third-party/nsh/crates/nsh-core/src/effects.rs": "81b914144cf5530716807a4973a2cad8cc4c9758e28eb42fe0bbab2ccbf34b8b", - "third-party/nsh/crates/nsh-core/src/element/ambient.rs": "53a5128a23a270869cfab8d84d19f6370b0602bfca36792ed3d5b87e3fa55480", - "third-party/nsh/crates/nsh-core/src/element/animated.rs": "ff20d5368ad071e8bb3f1e515420d9cbb10233723a1f99521b9baa679b328380", - "third-party/nsh/crates/nsh-core/src/element/asset.rs": "ecc4b40dbe52c239f69de438eab9444768bb5d7e4069aa15a51c3adc7477702e", - "third-party/nsh/crates/nsh-core/src/element/back.rs": "ef98d58af2314de48ec9fef54677a69d663a6fbd6fcbe90ba5f80f8c253bbce7", - "third-party/nsh/crates/nsh-core/src/element/builder.rs": "f6cb068d6089cbdf25a92d2a4112735095d7cfde9094ad70cc76d44e72fa33fd", + "third-party/nsh/crates/nsh-core/src/element/ambient.rs": "10c88b7ef0eb434a24b6310ea3e3f3b738c95171003ab18091922a0ba9d74718", + "third-party/nsh/crates/nsh-core/src/element/animated.rs": "a816accb583ad36200728a66cd7c6a914eb523f98577935e06cdb6ca2f37b6c7", + "third-party/nsh/crates/nsh-core/src/element/asset.rs": "44caf12fc89e1a66f1c187165cdea3aec6d0f5463484ce47f874f047dae694ef", + "third-party/nsh/crates/nsh-core/src/element/back.rs": "d61df6687953076f4ce51e3a2659cfffaa6efdc25ce4629270e665c99529b59a", + "third-party/nsh/crates/nsh-core/src/element/builder.rs": "56ceab202fd749b514bc18a0ee6aa97c4ca614a5df39d90805abda8827c4a6da", "third-party/nsh/crates/nsh-core/src/element/call.rs": "003815651b618e3442c7ecaf441980f96a222a7bc1ca29339ce63ff2dd8e1997", - "third-party/nsh/crates/nsh-core/src/element/callback.rs": "3dd174c891fbec17b472634b1ea26edf818554bc9985e9ed2b12832b249e411a", - "third-party/nsh/crates/nsh-core/src/element/class.rs": "67d7d65fef35073aa09304ecb5777d86f58964ddacf4409b721cd1751af85a1a", - "third-party/nsh/crates/nsh-core/src/element/component.rs": "d47e6dae39c17b7b5712705dc6a1803543acd59ed64937153d0bf04c0ef72cc5", + "third-party/nsh/crates/nsh-core/src/element/callback.rs": "883c4569320b7347a06b9b10254c5b670d7607eeba74a08e172381188d5666a8", + "third-party/nsh/crates/nsh-core/src/element/class.rs": "ffd5a96b6d841bee2629cc2252b5564e500bdb01254eec2a34cf8680c2c1d0c5", + "third-party/nsh/crates/nsh-core/src/element/component.rs": "be64b529851d4fc885b5ede4d0498ee25b51e6825ce4ceff9ca9693cbb4928e3", "third-party/nsh/crates/nsh-core/src/element/description.rs": "c15c59f4a48eaa2ebd0baee3042f5724136047af4737235d353cde9515b5fc52", - "third-party/nsh/crates/nsh-core/src/element/desktop.rs": "c199a05a80ede4b259ae7172093a9ff73132373d589a3165e172ab52b598b25f", - "third-party/nsh/crates/nsh-core/src/element/element_ref.rs": "ce9262e3506e7499468601c2137929f92acab4048f53d00079707a8a83a92db3", + "third-party/nsh/crates/nsh-core/src/element/desktop.rs": "d67d522fa5a55b50b1a1bfd1a5025ad3ae083b873d59e976cfacafde34311dfc", + "third-party/nsh/crates/nsh-core/src/element/element_ref.rs": "270646f2240b2ca1e8dcac01ab16b906bf01ea987b0b2bacf3333273355cd4c8", "third-party/nsh/crates/nsh-core/src/element/engine_context.rs": "fc9f4487a2ec47939119d2c6f5c75dc081ede1feb0422be8127b19fcb129fa1a", - "third-party/nsh/crates/nsh-core/src/element/env.rs": "9a2271f96f5087df265816a09d09377cee2e3699fa6d7a0c146d5fa0db1dcb75", - "third-party/nsh/crates/nsh-core/src/element/events.rs": "f92eef443beafe943fb3d732431a368d5698c9b9c394cb20fcb260403ac7c6aa", - "third-party/nsh/crates/nsh-core/src/element/flow.rs": "998e3074f7c1b411340bc8c23815a04fa8c8b786ff03b670e946ac51aa4d9fd7", - "third-party/nsh/crates/nsh-core/src/element/host.rs": "97f414814ce1c1d9a22774632e5ca128e84cec98443997186179b2c49b736ceb", - "third-party/nsh/crates/nsh-core/src/element/js.rs": "76d0a38402ed7204fb6d7566f04da41b9857ae3ff55719e3a83db18221839103", - "third-party/nsh/crates/nsh-core/src/element/json_host.rs": "5f34915e34e45bdc3853fbf242547336ab5c3cdb71e1d889bb238e881422173f", + "third-party/nsh/crates/nsh-core/src/element/env.rs": "e38c8dbaa7c278d9c73d6f25837653cccb5cf5f8a54c00ced5fe50c82c5c06d2", + "third-party/nsh/crates/nsh-core/src/element/events.rs": "02ceb6611071bd69081bc6abbb3e68c6ca0e942cbcc0ea5d3c645361e4c8474c", + "third-party/nsh/crates/nsh-core/src/element/flow.rs": "c8500f5c626397eddceab22a0e2d5a8f8a442f351fde7e4dcc9a8a9b55122122", + "third-party/nsh/crates/nsh-core/src/element/host.rs": "61525b6f149c1c1539e25cff150f776276b6e8ed18f0ccbba67db447623d2156", + "third-party/nsh/crates/nsh-core/src/element/js.rs": "48af64d3ad014457e319e2711d5b7e56c6aa6e72f7d95173595a30da99cf5ecc", + "third-party/nsh/crates/nsh-core/src/element/json_host.rs": "4af422f1e188325c102fbdfb14de370f274567b79552c28c5541c4d113630744", "third-party/nsh/crates/nsh-core/src/element/keyboard.rs": "6758cd822f942af1fffb0827ed50a6beac8b8c1e17137a60704100adec769898", "third-party/nsh/crates/nsh-core/src/element/keys.rs": "cea9dfabc574c0000bf6ef45383b9f77052dbd700bd694b5ce1107a11e2cef0d", - "third-party/nsh/crates/nsh-core/src/element/mod.rs": "b3a1de8a0b9f37b6f855fa175a2f8ef48bf82d9d933810d9bae8769afed5a6d5", - "third-party/nsh/crates/nsh-core/src/element/module.rs": "7a0da6818531590cea025df9f3ef796c621fd3f3cf21def2d1f32b00b869c362", + "third-party/nsh/crates/nsh-core/src/element/menus.rs": "a35b4b48d8773b6770d7afe0f9668891af00cc4859f23e5c99625efb83bff89f", + "third-party/nsh/crates/nsh-core/src/element/mod.rs": "fdcfb7c14f52a4783c292e68a742449161cfb5f6d5a1f8ceb166992d0a3d478c", + "third-party/nsh/crates/nsh-core/src/element/module.rs": "8522c703f41a1da75a533b79c9515518da64cf34969a8a64b91e952ceb4c96ba", "third-party/nsh/crates/nsh-core/src/element/once.rs": "ff9acaec13948c8fd25961654411d6f6917eeafb12470e91dd992d94b0568104", - "third-party/nsh/crates/nsh-core/src/element/palette.rs": "56a631d4e5bb3ffe02241618a7f16f999049946017de61a29606c12ca6b1c325", - "third-party/nsh/crates/nsh-core/src/element/prop.rs": "e8126a20d32bb563d596059694fdb185a4cb5ab4edf2ffc5e3c3c74e53a8823d", - "third-party/nsh/crates/nsh-core/src/element/reactive.rs": "a402bb0a8d426a1ad8214e25301563b26655fa747dbdd448fcd2ef66cd7a22af", + "third-party/nsh/crates/nsh-core/src/element/palette.rs": "d091ab5e8276cafcb63ae2f7bd1455610f3c183ef3a3fa7eec01618bcdc0337e", + "third-party/nsh/crates/nsh-core/src/element/prop.rs": "b5e4914f2865f32a633c7eb70abc73717ce9c1b367f25754ff3225aa85ea6c8e", + "third-party/nsh/crates/nsh-core/src/element/reactive.rs": "b2eeca4753fb1ce9ddc3c869bb3a269bb943c497f90913d1962d8f7ebf00d6b1", "third-party/nsh/crates/nsh-core/src/element/reactive/tests.rs": "c54d87ffdbd1d5747a66d0bf460d30d3df05ec8ef0d4c6830ba3ecc80801f768", "third-party/nsh/crates/nsh-core/src/element/renderer.rs": "05cc3b2ccea2a14b3ff34d6c4e88d872acb8b9d996e703102e45009b8c44b214", "third-party/nsh/crates/nsh-core/src/element/resource.rs": "eb8ecc943f67d93ebfd121b30ec109f3fdeea12749af76afe848fb49d84d2075", "third-party/nsh/crates/nsh-core/src/element/responder.rs": "3a594e2db4c01a60ef6222ea0ef28e2a3cb15c06cc6d32e38407ff5d36c922ff", - "third-party/nsh/crates/nsh-core/src/element/rows.rs": "46eb8e3c6387f863575b4f63f6d7e9872a1520c62fb6f42b21554c29302ec709", + "third-party/nsh/crates/nsh-core/src/element/rows.rs": "544028882b94de2e7487036619aa8e78894c72ee69c7a11fcc87847ceb383255", "third-party/nsh/crates/nsh-core/src/element/scrollable.rs": "8373449615434cb16cda67b14094673bbfecc183e25a5fef1f339a92d52fc96b", - "third-party/nsh/crates/nsh-core/src/element/services.rs": "e08c010776b7ff2f8c53308c64040a3add8a672c8c0a8a446384b4611bd4fc29", + "third-party/nsh/crates/nsh-core/src/element/services.rs": "79001ad1c54ff8088e2910c11acb04bd2ff87fa71aac666ad4752ab67baf6014", "third-party/nsh/crates/nsh-core/src/element/status_bar.rs": "2fff306388c47bfee440e1042f9143251fe0b210cd57a370e5e8a8247c537acc", - "third-party/nsh/crates/nsh-core/src/element/style.rs": "ce28bc3821a58fe744321e4a222a5449b17f7223f1b3312ab110b0fc3faeb1e3", - "third-party/nsh/crates/nsh-core/src/element/system.rs": "3eb7ac8b329c7faf7bbf15de930896a07c5bda9a16dbf61c3361114515739669", - "third-party/nsh/crates/nsh-core/src/element/task.rs": "0d1771ff562e4e703989ffc5117c28317037024f36a1f700612751d76a351376", - "third-party/nsh/crates/nsh-core/src/element/tokens.rs": "499edea5fc95d6c45c9075173865c11fc61b0778bc37a35a8f0b3814e2785837", - "third-party/nsh/crates/nsh-core/src/element/value.rs": "c8a7472dd81f0e2ad78960aca5cf16f019044e68b0b9755952be15f936e68ea0", + "third-party/nsh/crates/nsh-core/src/element/style.rs": "86a41c48bb4f7f49bbb5adfbfcc65b073167046460e88263f9cfdcd2cc681a34", + "third-party/nsh/crates/nsh-core/src/element/system.rs": "a351ae14cc12bd13bdb5a5008c480860455b8453cfee49b1d2ea297fb5672a91", + "third-party/nsh/crates/nsh-core/src/element/task.rs": "9b724748039710438a00a8b426a98687c06c945c97bdb1d00c43e93315459180", + "third-party/nsh/crates/nsh-core/src/element/tokens.rs": "c47ee4d182ec65b88772c56165a12b80c87455084fd795023a66cf811183565a", + "third-party/nsh/crates/nsh-core/src/element/value.rs": "c4ff203bb00b8a9d7ef0e50907b0b07132f94b75415a9952c5e87ba1dbdd3408", "third-party/nsh/crates/nsh-core/src/element/virtual_list.rs": "e14f25597cca6993885202338a4a78499827d0db5033d5756329ec3cc5aa6874", - "third-party/nsh/crates/nsh-core/src/element/watch.rs": "6f1b555ffbb7468b0ec2bf97c3b87ed37fc60a122f458984533a65c63b5803aa", + "third-party/nsh/crates/nsh-core/src/element/watch.rs": "cb14db90caaeadbb99ce1c7e0d294f006e5d3b8f7b48ce3dc17f387f9757e460", "third-party/nsh/crates/nsh-core/src/fenwick.rs": "dbb7e9e1cb1dff6b52d09c5341f85b29ea39af69ecc6bef88637e271d2a4a155", + "third-party/nsh/crates/nsh-core/src/file_dialog.rs": "53b6c730def8844c7425520147920feb46da5b076aa8757296d1390c65347fda", "third-party/nsh/crates/nsh-core/src/filesystem.rs": "b6211b899a5601b7d989e540775a611ea4650875aa4a0440029bba737dee145a", "third-party/nsh/crates/nsh-core/src/geometry/length.rs": "ca5057f8fc5f3c8ba291e218e944321376a93326a4cf718b347fbb7880f7332a", "third-party/nsh/crates/nsh-core/src/geometry/mod.rs": "1facd3aa45ba022bbf3c3b68fae51479e5033b1ef6e16bfb69e294c6a2a39373", @@ -991,158 +1029,164 @@ "third-party/nsh/crates/nsh-core/src/geometry/unit_types.rs": "fb33d6819432a8727305ca0e1aa0937e2a642bfe6068483822bbbc6b540d84ea", "third-party/nsh/crates/nsh-core/src/geometry/units.rs": "8e061b96ca9860bfd97c4aca55d220736a757658d1f3a0a2b3cbfb8abc03b416", "third-party/nsh/crates/nsh-core/src/grid.rs": "c767908b0c7b20842d6e40de6c556d8a4324438705f6a476a41dd62e8db33b19", - "third-party/nsh/crates/nsh-core/src/host_channel.rs": "bbe43f98dead381ad9b25c9eb4236135d6ba244ec03c072d68e059db4c7515ff", - "third-party/nsh/crates/nsh-core/src/image.rs": "941ba214aa42755a10aa92d975c39bacbe7a8d857529ab17a621714e5e73f34d", - "third-party/nsh/crates/nsh-core/src/image/cache.rs": "7db3a77091226235917176cf6320784862b451cafb3d791ee2f19225537af59e", + "third-party/nsh/crates/nsh-core/src/host_channel.rs": "8f1ea8a28ef08dfbba7e5ad82f3b8c30ee7d01f2eea3f65f4e66303b4a6ed45d", + "third-party/nsh/crates/nsh-core/src/image.rs": "0acec293ffde275b0b8955530aa6bc6c82f9270dd41ebfdc4929fa8769c518be", + "third-party/nsh/crates/nsh-core/src/image/cache.rs": "35a895691d99b1775d9a5d5a9e3bd244ce08462d524ab466bef4c56a5b8c1f29", "third-party/nsh/crates/nsh-core/src/image/cache/tests.rs": "71d10a0bc2240f975e14cf1d2d2b2168a6cb3778622766cce14c8c38b60f4f8e", + "third-party/nsh/crates/nsh-core/src/image/decoded.rs": "1ace84eb5b7c3501b6ec479a477256f034f855687a263c109627c49d6c07465c", + "third-party/nsh/crates/nsh-core/src/image/loader.rs": "a49a9e209a9db9c626388d4ca4f932bc491a1c9481e6eaf3b92935205be5e95b", + "third-party/nsh/crates/nsh-core/src/image/loader/tests.rs": "264202bc086d0059f7bf2e9b7a326d6ca1f5ac5410e663acf379b9a46c273146", + "third-party/nsh/crates/nsh-core/src/image/memory.rs": "7c0fdf17dfcfc14ccedcd608fe5432a18598e80483d178dbcfdb0fcb89cb8454", "third-party/nsh/crates/nsh-core/src/input.rs": "bc122a5485fbd3c118c464fa63ff77047a1a7aeecb951c29bfc2275dddfac9e8", "third-party/nsh/crates/nsh-core/src/input_tokens.rs": "db397ccd8f09e990216f5eb5761a4b8279fac413deddc6ae76cd5b2da9656449", "third-party/nsh/crates/nsh-core/src/js/animation.rs": "a6fad632f9c25381f3d577d16f7003bc34d993afda591637955b6e8bb315fff6", - "third-party/nsh/crates/nsh-core/src/js/canvas.rs": "a11ed794a7be9a2ac245a56714f03c47fed359b9a9c7001fe8cb729aca5b0d41", - "third-party/nsh/crates/nsh-core/src/js/host.rs": "389aca81ed5f35e50c6189f55e0bb719c763133529cf39a7fc6a21c7d53c2d04", - "third-party/nsh/crates/nsh-core/src/js/host_values.rs": "050e177cfd02a5ad7663a19a270ab07fbb478c3668dd900126eaaa66ff44af8e", + "third-party/nsh/crates/nsh-core/src/js/canvas.rs": "3a2360d81fa6ed1cdcc1cb815501f5b7d7cf6440ac2d63849f0afe038599ca27", + "third-party/nsh/crates/nsh-core/src/js/host.rs": "e6398ce92bd4716f255601c587b8b17fe36c3aaf2d6811ace8bac1ce6c8dbaed", + "third-party/nsh/crates/nsh-core/src/js/host_values.rs": "455d2407320cd8f752cfb961511a579f1a34cd684c39b5ac3d566e789b76493e", "third-party/nsh/crates/nsh-core/src/js/limits.rs": "44bafb43b6fc14a6899f7f7219fb8d7505485179bc0ec470f76d9266d9b35fb3", - "third-party/nsh/crates/nsh-core/src/js/mod.rs": "56072b3237b3a360faf03a3ef6eb50a376d5441fa350808e180a701070ed913d", + "third-party/nsh/crates/nsh-core/src/js/mod.rs": "99df5ec8c803ef4db9d6c5b027bff6d3b2b7265db0ccf275cdefdfee270466c6", "third-party/nsh/crates/nsh-core/src/js/ops.rs": "508390631590a81d693bfcc710aac4524806494b9dea157e7450ff3adf95bdb1", "third-party/nsh/crates/nsh-core/src/js/protocol.rs": "19c4c34368d2a05a196e580d5813159f44e3d8183b9c6e9168f9b3ab3db4f91a", - "third-party/nsh/crates/nsh-core/src/js/style.rs": "958f249ce06758f31def2a3a3808313cb482ecdca05fe24dee65fcd90b13ec60", + "third-party/nsh/crates/nsh-core/src/js/style.rs": "85ba10f88ec1dba7208eca5f1f3c5743e72553dae8d2da9f85f83bd22b500a2a", "third-party/nsh/crates/nsh-core/src/keymap.rs": "bac743f0a4183021c731a11af9dc85a056c7525bbe0f6d7e350b90e0c3a25ac5", - "third-party/nsh/crates/nsh-core/src/lib.rs": "cf763b79533656e9fe34af7ef0edec9cdf9025cb00fccb8b88d4ba456c37927d", + "third-party/nsh/crates/nsh-core/src/lib.rs": "af2dca334da8691785323251f3fce3a29e3d4b0d9841dd75237426adfcbd9df2", "third-party/nsh/crates/nsh-core/src/mobile.rs": "09353d65f9ec36f7c31bcb2d630267dd5967135bbcde7105ed8b8afdcbf3654c", - "third-party/nsh/crates/nsh-core/src/mount.rs": "af1ab38be1da6097b73838587cb7c1a15d76a0624821c0458d41862697c8285f", + "third-party/nsh/crates/nsh-core/src/mount.rs": "ae4da99d124afeb8e3bb39abb986bae7dcc6a2ada55297001c598439d5d81b0e", "third-party/nsh/crates/nsh-core/src/navigation.rs": "d79dd1d10a04157cbc5a383dc7b01aee820e6a33a16c682d3e43dc5d4909f5ea", - "third-party/nsh/crates/nsh-core/src/open_url.rs": "36ffda8c376b6dcac8af29e5df2cc9a58062c4533f8fea00511c316d90501503", - "third-party/nsh/crates/nsh-core/src/ops.rs": "8b11afd632a6dfc012fc56d902b7de06e163f07ddcc642320e2b8a4541434648", + "third-party/nsh/crates/nsh-core/src/open_url.rs": "f8fcb90373efbb57a4d746eee844e8871d6d5d9fd7c78ba4b7468fd37cabb0e3", + "third-party/nsh/crates/nsh-core/src/ops.rs": "a10e6df94d5e652110286b54c4f9c30c361630d84441a3e73016a3d8b2035179", "third-party/nsh/crates/nsh-core/src/overlay.rs": "cc25a4f12c546f0bf1b8d1a37a44b5b83e876ffd342df91ccfda9a40029ef057", "third-party/nsh/crates/nsh-core/src/picker.rs": "1067e7ed94f274a5ea7f0b768075e09a5c9dcebf19cb388b54beffb7e7ea53c9", - "third-party/nsh/crates/nsh-core/src/platform.rs": "502b65496a44866528399cd92262ab83d96f6b51595587032d50ee0e591b3a4d", - "third-party/nsh/crates/nsh-core/src/platform/clipboard.rs": "9819f760550e02f6032598898f9c956724fb2409814ee1a4743d871d7c155db2", + "third-party/nsh/crates/nsh-core/src/platform.rs": "c210496bb1db629b2bfd214e5e6a62c759276ba6b643d97c86ae49ce65edbd3b", + "third-party/nsh/crates/nsh-core/src/platform/clipboard.rs": "a24e05829a7a2e7d7522f1778c22d1b86570c6ba28fb0bcb34d9065b590bf334", "third-party/nsh/crates/nsh-core/src/platform/clipboard/path_list.rs": "c03e2785b13fdbf30d8a35557edeb84d220622991b5a8e06a3f97db9fdb36f6b", - "third-party/nsh/crates/nsh-core/src/prelude.rs": "04528ac80e77f273708183e36c95f75b9cce39582b6870611e5e7a4965a5ad92", - "third-party/nsh/crates/nsh-core/src/props.rs": "3809238c2704203b39e0aca39b40b820126cd59587f782efa8f77690924d5e29", - "third-party/nsh/crates/nsh-core/src/reactive.rs": "0d2d4c952be9b7f1c6c9c12898aa5d63b30e9401e8621194fda80904470e41df", + "third-party/nsh/crates/nsh-core/src/prelude.rs": "a3dd89e075e6351c8d4e2ec5f287ccb8eb0c62d3bf3058a25c0ae2dda760b7d3", + "third-party/nsh/crates/nsh-core/src/props.rs": "d51d0986102a2da4ad46b15cf9a477549b6c431454272a306e6cc7f4333667c5", + "third-party/nsh/crates/nsh-core/src/reactive.rs": "8892ed1ab7e03c258951e64a1c195aee8774fa874d2b2e9419656b2f9638e7a2", "third-party/nsh/crates/nsh-core/src/reactive/tests.rs": "f8c773d266b01c412849c896bae5d9607f53f752d0b1cbb5974ba0b19a341648", "third-party/nsh/crates/nsh-core/src/resource.rs": "ddcb0736e76fcfc5c14d5e1fa365e3ceef821d4a0a14fd3925147b6a38db402f", "third-party/nsh/crates/nsh-core/src/runtime/accessibility.rs": "975e7f00ed54a49c8089b4114e1b611d598ebe6313fc811e678a0537c633093f", "third-party/nsh/crates/nsh-core/src/runtime/android.rs": "34237340429273d617826075c43a8cccf8cd376772344115c6a81d9f4a384381", - "third-party/nsh/crates/nsh-core/src/runtime/clipboard.rs": "c6c926c52ef49fd7056f91045d7b350c15f493c73007a49e3011834a63ce145b", - "third-party/nsh/crates/nsh-core/src/runtime/desktop.rs": "d59a6cdc0efc01618a15b0c676fecc035965d255146e4ddeebc0ab5bf43bf798", - "third-party/nsh/crates/nsh-core/src/runtime/device.rs": "52ad86d36acd4d11e4804653e30a75e3b2314ade6b9180bfeb39cd0c3c06254a", - "third-party/nsh/crates/nsh-core/src/runtime/fs.rs": "de1dbe62a00520b810a3d1a48341526c88ce522355561850215ef7aae8ec8e83", - "third-party/nsh/crates/nsh-core/src/runtime/host.rs": "2a88979dbc31c75ea75a51630b3dfb0f370688e94d0a5b0d918020b7de11798b", - "third-party/nsh/crates/nsh-core/src/runtime/image.rs": "9865e738addbdeb2924174f2aaf2c2d0a9ee28f7fae49cf2e420e36d4a0f4731", - "third-party/nsh/crates/nsh-core/src/runtime/mod.rs": "d9112fdd2b638ab329195c413fa36bcd76c5b99134c35ac775e0fd8a4502b98d", - "third-party/nsh/crates/nsh-core/src/runtime/net.rs": "f7e78e8c527a071c700047e1aa80677c8da0f15701d76573c03ec040be801a73", - "third-party/nsh/crates/nsh-core/src/runtime/os.rs": "fcdc836843f9317e29a5dfe825bb5fad1d4548edae46ac9078e8c2416f658fcc", - "third-party/nsh/crates/nsh-core/src/runtime/picker.rs": "bbcbb24759b0d2d6b26c96ed28449c80d550f62d02c04a40ab9ef69b77e74abf", - "third-party/nsh/crates/nsh-core/src/runtime/process.rs": "92a57b5a72087fc58546335325b33f9e3031062f7e5e174c2de17c1c49f3ede6", - "third-party/nsh/crates/nsh-core/src/runtime/storage.rs": "ac22c147fe18831167f618ebe2334b99e0173ecd55a72f94780a97e254923ffe", - "third-party/nsh/crates/nsh-core/src/runtime/websocket.rs": "aff1e642958518d5ad8f12697f02c42eb893ff46769509188e36ce0f678256a8", - "third-party/nsh/crates/nsh-core/src/runtime/websocket/native.rs": "6e4de25a0ad922bbe218e274445d69732e5fe30306fce1138135c2e1ef2b2343", + "third-party/nsh/crates/nsh-core/src/runtime/clipboard.rs": "9e60e2ec03b2b28dc763f86012e3b222b1566a6d62e66dce5c7a367a360454f5", + "third-party/nsh/crates/nsh-core/src/runtime/desktop.rs": "8acf11661b5a121c50bc64aa4b2b7bd4638a6dd6d9fd1b380fa882829f899f01", + "third-party/nsh/crates/nsh-core/src/runtime/device.rs": "b4a28774b97f826243abdd46c324fbe754d111b7cece9a53cfdc70bc85a91858", + "third-party/nsh/crates/nsh-core/src/runtime/fs.rs": "b328b9772a451d6167c606cf62f6819eb2c150dc7bf1443627cb30049875bf69", + "third-party/nsh/crates/nsh-core/src/runtime/host.rs": "6173ee1ef820bfeb20c3854c480afa6c6ad9babab92ead5fbbea8e892392cd03", + "third-party/nsh/crates/nsh-core/src/runtime/image.rs": "fa0bef247871652dc500b45cf0f9768504907b9d5b73efc999f6560f5f6f1599", + "third-party/nsh/crates/nsh-core/src/runtime/mod.rs": "a6a9b14f9cac4969581dedf3ae82af0a29b1d60be1a8a4791616c726b1b43f3f", + "third-party/nsh/crates/nsh-core/src/runtime/net.rs": "9fdfa05c9d5b5e7ce76d28b6c13d8f7b05a81cdb950398ba26a3b07ea2f281b1", + "third-party/nsh/crates/nsh-core/src/runtime/os.rs": "2ddb9f4c1a111e2b6c41f98e6aadeacdb2368241871c0fdce948931d5e36ffc7", + "third-party/nsh/crates/nsh-core/src/runtime/picker.rs": "2504a0eb69b821b2ce72f0d28f45cb4571e3663fbfce756bb267f43a5ecf0925", + "third-party/nsh/crates/nsh-core/src/runtime/process.rs": "fdedcd8e7a0664e41e271b472efb93bf8b034179a1a3391de89f734ad90137b6", + "third-party/nsh/crates/nsh-core/src/runtime/storage.rs": "d8a9a111a0cd5c2b90c0622a6953b0728060e87d2b0d9d7cfda7c48a9ab61fe2", + "third-party/nsh/crates/nsh-core/src/runtime/websocket.rs": "ae80c6acb50aac9c7f19bb53532686ee07992113fc52dd926de5fdd771b342ad", + "third-party/nsh/crates/nsh-core/src/runtime/websocket/native.rs": "e72bc1ad90bf762f6ab389e648347a7105975e642cd91a223872ae2843ca835f", "third-party/nsh/crates/nsh-core/src/runtime_data.rs": "6972c36cc09299c492d99a48ea97932712a1a5de72d1efdabaa6914f27eec7ae", "third-party/nsh/crates/nsh-core/src/scroll.rs": "e778468cadad4b824416a7aa6003cbfa27cceb7d144059b094ce59b906c3e82a", - "third-party/nsh/crates/nsh-core/src/selection.rs": "ef46c236c016f5958fc312ce63e63a655b8795a2cad015b5495f545429c86ead", - "third-party/nsh/crates/nsh-core/src/shader.rs": "aa71db5a017cc2da7dd335b2fa937ed1e6d1ce27769e43a4a9575ae3211cfe94", + "third-party/nsh/crates/nsh-core/src/selection.rs": "eb81e591bd4a9bb74eec6b442c39f94015274769d5b5ee5e36775a3544518421", + "third-party/nsh/crates/nsh-core/src/shader.rs": "a0348ed42fe76580442c837beac27ce0891828fc5711e3670fe4564533790301", "third-party/nsh/crates/nsh-core/src/shape.rs": "83ae1d63179f16796ac750ecae9fb38dde64cac64820db915d47659e8c7f362b", "third-party/nsh/crates/nsh-core/src/storage.rs": "5f00af26befbf99eec5d084cad0f03b186d48453cad9de3efe1b982de6fa02e8", - "third-party/nsh/crates/nsh-core/src/style.rs": "0b211ca15faf6a207f621e03c0c9bb115688c6425c7792825d1ce8c0649c2d00", + "third-party/nsh/crates/nsh-core/src/style.rs": "fba4bd1961e100619e01758b4554ebe0a426295edcccd077f68fabbf71a8da82", + "third-party/nsh/crates/nsh-core/src/svg/drawing.rs": "ca6e89a825508286228f3ef61109d22fce8d9a45d4bd554365bde12ceca5368a", + "third-party/nsh/crates/nsh-core/src/svg/drawings.rs": "13a7691025b558fea468e5a5db7b25ca0e7406c3b1b1b86809bc981726405773", "third-party/nsh/crates/nsh-core/src/svg/layers.rs": "ea5709f292a9135c2661a87bc1a64310bc7a27cc3738100db59c7af656691768", - "third-party/nsh/crates/nsh-core/src/svg/mod.rs": "18aa8a92536995ca86aa65dd2bc07b0a8468aced7089b544affdff3eef4a5ee6", + "third-party/nsh/crates/nsh-core/src/svg/mod.rs": "1ce7d9c1f9416b1a906b8f3d96259a4a3466e37852b4ae8a095ce3f9a33ce4f7", "third-party/nsh/crates/nsh-core/src/svg/testdata/half-alpha.svg": "43eb1f002e5ede97714d406308232adc064e7d33b7aab1e98502a56c23056f19", "third-party/nsh/crates/nsh-core/src/svg/testdata/icon.svg": "40bd48703eb318ebf7bb57a8f90b4b1987781500f1f4c69bac568e83a0f068e2", "third-party/nsh/crates/nsh-core/src/svg/testdata/logo.svg": "c7f4d73d1828860ec1ec70da096bd121cda0fa18d2533945360b039c734a605b", "third-party/nsh/crates/nsh-core/src/svg/testdata/text.svg": "7585c858450ac674151db5baf46ceffe55df4c2959e5a8e06b7c5c5fb77aa537", - "third-party/nsh/crates/nsh-core/src/svg/tests.rs": "7532a171fd18277140d75ffc0c780162445efada9ef170d9c7edcd7a78192557", - "third-party/nsh/crates/nsh-core/src/tailwind/mod.rs": "595c87c4318bfcb1716517242f3d7490f8937cab0b42ce287162960cdff54057", + "third-party/nsh/crates/nsh-core/src/svg/tests.rs": "83d120176ba48399d9d1f458c72a4e50dfdb300ee6d7fc552a2c4cec83d28fa0", + "third-party/nsh/crates/nsh-core/src/tailwind/mod.rs": "3a6ea7cf7fd837051245949cd578acdcbf8290d228621141f29e0274792fa983", "third-party/nsh/crates/nsh-core/src/tailwind/palette.rs": "5ca161df61a76d0615f24d2ed9d31ddee1459d3bdcaa153146adbbf9abe78199", - "third-party/nsh/crates/nsh-core/src/tailwind/tests.rs": "82bbb94981ef855fbf025f56e7ef2eeaa716d3f9fce4d202a69c495ad5cae25a", + "third-party/nsh/crates/nsh-core/src/tailwind/tests.rs": "7107d1ca6ecaa70231767e65e16154909576afa0a67226dca43989773832f8c3", "third-party/nsh/crates/nsh-core/src/tailwind/theme.rs": "825c1046fa9a292c0adb9dc1205cc58673ce05b33c81f9e3df610de1394d1494", "third-party/nsh/crates/nsh-core/src/tailwind/utilities.rs": "be905a3481d059acd5980cf3a1127b656156bb1a0f9d00bc4c317159d65f9421", - "third-party/nsh/crates/nsh-core/src/text.rs": "84e26f30b26567e26359941bfb02c15c0fe2a822acb9fddb6022bab0818ae0d9", + "third-party/nsh/crates/nsh-core/src/text.rs": "12d3b8ae96ed3395abccd99d5948c8e3c170082c0bcc1f5e822aa718ea9f561d", "third-party/nsh/crates/nsh-core/src/text_edit.rs": "80061517ab77ee0a3a056c2063b94d8a28f1540dcc4ec2f64acd805e4246323d", "third-party/nsh/crates/nsh-core/src/text_source.rs": "8a2459beee5d9074a1b3797e275f5e3a5b86eb63399c06917f720ef8bb7f8f25", - "third-party/nsh/crates/nsh-core/src/time.rs": "c342c2e1d620cd2e8d44691e10064e56505253b696f543c1223c6ca4e34a137d", + "third-party/nsh/crates/nsh-core/src/time.rs": "f6942879ab021cf1846715f0fe56d279119d177cb393f9dd20c8617ca3f4ef25", "third-party/nsh/crates/nsh-core/src/transform.rs": "6defd5ff281f6a400c7184bb7f4f30b4b710d7f6e8fc19ed539ff1b604952836", - "third-party/nsh/crates/nsh-core/src/transition.rs": "b94ccebceb665ff44cb454ce12a15466cfeca27c7ef5f0187cb1bc37c875e787", - "third-party/nsh/crates/nsh-core/src/websocket.rs": "8438f7eb89366e83af5fb5795ae69010b0c1a9714531ebdade6284e4a5c146a8", + "third-party/nsh/crates/nsh-core/src/transition.rs": "67a25751945eb32aaa3fdf6165ec9bbf68416f996ba2cb8495c90f77fdda93c9", + "third-party/nsh/crates/nsh-core/src/websocket.rs": "a0b33809b289dc2199593bf0187c98ffaa7acf496d07bd0fa748cee98e3d04c4", "third-party/nsh/crates/nsh-core/tests/attachment_compat.rs": "a4411e6572830fe5ace913c6f081d9b52b022054d0f9ad688e52fab03e5a9d78", - "third-party/nsh/crates/nsh-core/unitless.txt": "c133ee08cb40491c464184a87cd70463bc9d25c5f3ce0a1bbf0398c708cc58e3", + "third-party/nsh/crates/nsh-core/unitless.txt": "4590c8ed1bacddb3cc25296c75e81619635f76dd30236c0d00c511f8efb297cd", "third-party/nsh/crates/nsh-core/untyped.txt": "2dbe1d080c08c33debbf4d4c27ae0a08996efa87dd072494b7d0ce712109d2d1", "third-party/nsh/crates/nsh-dev/Cargo.toml": "f6f8b7604f4e2bc8212779f46c74f568247f8d9f853eaef4417418ea8a6f4ab2", "third-party/nsh/crates/nsh-dev/src/client.rs": "541352e633968e5ff9dc3fa6d5f3ace8fef5cfb42465d623a2d19331b71d1edb", "third-party/nsh/crates/nsh-dev/src/lib.rs": "5ab70265d0446186802b942c849ac83cd0bb7cfeb2ca3f69d163dd56bbac3fff", - "third-party/nsh/crates/nsh-dev/src/protocol.rs": "2cdd92684d62426970157b33b019371575dd5b121ea57c48ee701ea965882781", + "third-party/nsh/crates/nsh-dev/src/protocol.rs": "af0cf4d64f3cf4864f269fa4bef0a6aeb02415f545a4f88dcb205c4945be8941", "third-party/nsh/crates/nsh-dev/src/server.rs": "d6cdfeb509d835c792b0b70320d9b916139ea5c3a206f865a28e5eb35a73448d", "third-party/nsh/crates/nsh-dev/src/site.rs": "2f54a4ba0b0c7f7c7d24da8719ceecf50e4ef4cc1adf49fcf15305fa6d8bc25f", "third-party/nsh/crates/nsh-dev/tests/roundtrip.rs": "b4b5fcdc8ba628dab768744fc2e13d899081a092c605d1382f767bea4c021fa2", "third-party/nsh/crates/nsh-engine/Cargo.toml": "95add5092feb9c5c20813e6a7e02224de311ba6d4d82f4fe324c3a77476cd6b4", - "third-party/nsh/crates/nsh-engine/src/access.rs": "37cb4b18aa2f1d47325969b035bd08f5d6cb9db6c5b437a6a9aa7bd2677f6707", + "third-party/nsh/crates/nsh-engine/src/access.rs": "60d6ab9951c53534dbe0c7dd9e2b2b4ea73bb5348ae2c89064b20eb2962c0113", "third-party/nsh/crates/nsh-engine/src/app_directory.rs": "bf99d20949b392e5b919f8185a6e234308d53ae7db02c29b0d18ce678b74986f", - "third-party/nsh/crates/nsh-engine/src/browser.rs": "c946eca81d675708f7420e300f63621adf2f43c9c0101eeea39b803c8697ff42", + "third-party/nsh/crates/nsh-engine/src/browser.rs": "c3fcd5eaefa24ced199b1d23244689d9dc51ff9378874376875ca336770712e3", "third-party/nsh/crates/nsh-engine/src/cache.rs": "f18f8a5658d02c55a8aa528302292ac39ee3192d372615a65f87fc300c3b4148", "third-party/nsh/crates/nsh-engine/src/chunks.rs": "2bb7d344b622dc41d5aaa5ea958a0c85a5dca525ad64d0f5eee7bd730dbcee5c", "third-party/nsh/crates/nsh-engine/src/error.rs": "54bce4d455c05a1c37b3e0ce8e46271492369855482283ffae3851605b44bc51", "third-party/nsh/crates/nsh-engine/src/host_channel.rs": "f878cf60930a4c138f340ec4ab8fc144212516df89bacdb6a7b5c8126a0d5171", - "third-party/nsh/crates/nsh-engine/src/lib.rs": "cd3567300128a35f8487ebe60b8190e1df601aed406d4ea2e6fbb422d9cef0c1", + "third-party/nsh/crates/nsh-engine/src/lib.rs": "2d7b3aaf7229853097a2a071ad6fa5c8b5f1cf0bcddaec0432c8a8aaaf040302", "third-party/nsh/crates/nsh-engine/src/local_storage.rs": "bf75d06d4d917a14b6d4f08abb10d385c68ed4ab50b6c70c4851cbc72ad3f631", "third-party/nsh/crates/nsh-engine/src/net.rs": "3a1fe7de92639eb17aac9e97156b7a9c6cf9384bbd9a973548c12434b48910f9", - "third-party/nsh/crates/nsh-engine/src/perf.rs": "708cf1ee7d21d0de43d3d3b9e3c5a17a90d7035c33a18091603c798cd86d834c", - "third-party/nsh/crates/nsh-engine/src/script.rs": "64bf649a014981d9d1f1ae9d59249f9b52ce7e31aac72d77bdc5e8b9a194a51d", + "third-party/nsh/crates/nsh-engine/src/perf.rs": "24975caf793ee7fa6ffb987573bbb0c60852b335d7000737bd516ae7c94194ea", + "third-party/nsh/crates/nsh-engine/src/script.rs": "f507eebd314c3084f96c8e41b8b4e198fb34c8a985d0f23d2f6d2f20efb13afa", "third-party/nsh/crates/nsh-engine/src/shader.rs": "4dee74575191fabc1e957a7141d65b0febc10839012796d6a4be35d62075f21c", "third-party/nsh/crates/nsh-engine/src/storage.rs": "9c5935156c2da64545503929115253fac2e7db65466749c443851b2bc85c1a59", "third-party/nsh/crates/nsh-engine/src/symbolicate.rs": "974a80ff547e60e682ce2691e83346f02123c549b5f92899e84dc41b29fdd094", - "third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs": "103eee8a9c467a3d4af1f73a4a387f7b64f8f84015373d2fade77ec4be9787c0", + "third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs": "3df0db54737661f597f538414225d8527f371f28be38d73a2a26d61764fc6cf3", "third-party/nsh/crates/nsh-engine/tests/engine/class_bench.rs": "ff67ab06243c03acdc719b7f8c1c992a6be3be4d71b6f2d69916f5a86a47a6aa", "third-party/nsh/crates/nsh-engine/tests/engine/clock.rs": "069baba80f681734c5848ab693f2a9f4e13df5f4cf19b1a4f9373f544472eacd", - "third-party/nsh/crates/nsh-engine/tests/engine/compiled.rs": "cacefce8bd562c48ba368f9297dbe31a30a152f580f7aa876db58357b80a07ab", + "third-party/nsh/crates/nsh-engine/tests/engine/compiled.rs": "11dc0d854ec236a06e247c1e4b825c12516ef42e51f8222d76363cdb6cf67f82", "third-party/nsh/crates/nsh-engine/tests/engine/component_bench.rs": "18b53b2ebb2459d48f799c7b1a1850527f97381fbd97f05748d347db945293f2", - "third-party/nsh/crates/nsh-engine/tests/engine/components.rs": "f3895bdf447a2e26aea275e77b16dc13c2c33acbb56da01da025e4c36dd60103", - "third-party/nsh/crates/nsh-engine/tests/engine/counter.rs": "74be45e59cabdf58a9131bca87597aca51946c98266a654c382c45fcc3486e17", - "third-party/nsh/crates/nsh-engine/tests/engine/errors.rs": "0bc82cbe6054777dc5b627a1b1a3449b3f85488914a5503e8d3c95feb63bd79c", + "third-party/nsh/crates/nsh-engine/tests/engine/components.rs": "824eee7266543d59e5b964219bdea227bc6f77a05127d1a1a662869f93c1b24d", + "third-party/nsh/crates/nsh-engine/tests/engine/counter.rs": "56ab0289115deeacfd1378c166c73f83d549acc7e31aedd672a9103c39b72971", + "third-party/nsh/crates/nsh-engine/tests/engine/errors.rs": "fde79a821ab305bf81542178ed4aa0d064fc322491ee71ea4365533081fc885f", "third-party/nsh/crates/nsh-engine/tests/engine/external_wake.rs": "ab325f1e07c45d6f1aa8030b2049d7b4417bea08dadffdd8b6e5519e52647d16", - "third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs": "f22a160981ef3dbe6404c52019c5687cb32cbdcb3061e8eb781e624354cd06ae", + "third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs": "eeea981e0b60a707632935e3bbe890ce1ffd703a514a267e9c5a99ed4396aeaa", "third-party/nsh/crates/nsh-engine/tests/engine/focus.rs": "e94b35caaf073ea39ed9cfd1a7bf3b3c5fb42832cef476bb011eab67aece7801", - "third-party/nsh/crates/nsh-engine/tests/engine/fs.rs": "e789756808bdf1586ba20c3ba7edec554f00cbc6412c1f8635ce43dcbbe122bb", + "third-party/nsh/crates/nsh-engine/tests/engine/fs.rs": "c837defbba29546cebb93ebf1351bc7d60bb59a0bc329bd42e43608ec55b759f", "third-party/nsh/crates/nsh-engine/tests/engine/handle_bench.rs": "a555b84dd0c4319e1a15e4876cbd0ea405f2b1f02e867f02540d178b567ba7d3", - "third-party/nsh/crates/nsh-engine/tests/engine/host.rs": "21067a76fb29d76ea5c33b047f45ffab540d3205f45f41f1a9b65ead580ee7b5", - "third-party/nsh/crates/nsh-engine/tests/engine/host_channel.rs": "833a916187b395b1410e56c950774bc67fffb16b7f455d6258183aede28cbb67", - "third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs": "3cac5cbbe7b217ef9f857193343ce2dc1ba68cf9077dd2fea196eb633536c2f9", - "third-party/nsh/crates/nsh-engine/tests/engine/hot.rs": "7a8ce1fca684ac47ba9ed68220bbc27a6c77a384bd80fbc91bee7af4e7280c21", - "third-party/nsh/crates/nsh-engine/tests/engine/hover.rs": "6560a6f413c0441a9989ed7097724494ce2e1bcf60905e67afd968427e50e08c", - "third-party/nsh/crates/nsh-engine/tests/engine/images.rs": "886ed7653125b32082672de0170b0248d8bcae2232f7ed6dd4ad4d029fc3bb06", - "third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs": "579dae72943832c0b756d7312f5b66833b118a9d364eef1b4d4fb890c46fec33", - "third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs": "7c6131fe618af0ddfb1a5e9d5a6721bed5808e4cd42cabf8d82fdb982d8852b2", - "third-party/nsh/crates/nsh-engine/tests/engine/list.rs": "d9072c0a1bb671fc360366e125462441a858473b5c8e60c2a5278cd4f55bf1d5", + "third-party/nsh/crates/nsh-engine/tests/engine/host.rs": "c8a1438a5538b7296369fcce54d7f6bfc04025cb9513779916e71b586245130e", + "third-party/nsh/crates/nsh-engine/tests/engine/host_channel.rs": "09bcc2c5918886bfe497e96b09ffb8d34d5a8022b9f6ace102fae5668d012666", + "third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs": "7a86452911fcf08b01ae3ed84359c24ff77bbbf2ce5da444ae85061e8743e6f5", + "third-party/nsh/crates/nsh-engine/tests/engine/hot.rs": "334b305659fc19b9d3da70eecd067583547e7bb03ca47a28d43e0ec89b35db42", + "third-party/nsh/crates/nsh-engine/tests/engine/hover.rs": "22c8c94de0e553917e07569304a9f221abbb7de6e87d851973febfd86f5bf028", + "third-party/nsh/crates/nsh-engine/tests/engine/images.rs": "da240f8f763198115f870e6489f159b5a1feafed40860e2a3f671c81bf138431", + "third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs": "45b9874c093f83a92507d45192f7dfe0d45d285d03196ba8174f4180efc4739a", + "third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs": "329c38ee37f7a25861cd5eec131efcdf132ef96ca8735a6e1478273ad363f734", + "third-party/nsh/crates/nsh-engine/tests/engine/list.rs": "6914189e7bd9842ea820095b0e02f95ac0fe579b5d99818017175528a8f53f0d", "third-party/nsh/crates/nsh-engine/tests/engine/list_bench.rs": "54cb3ab6e5b415e02cc7f8fb6e79f0a2c3ca2f3fc022fcb5f4a545f25c179080", "third-party/nsh/crates/nsh-engine/tests/engine/logs.rs": "22201bd9432b8e3801064aba2a5b77d3a64f4a4b148133af5a39e2e26a33c0e2", "third-party/nsh/crates/nsh-engine/tests/engine/main.rs": "cf95b338589154575de85ff5cf4029a1baed21281c2bb7a2791817550c02e47c", - "third-party/nsh/crates/nsh-engine/tests/engine/native.rs": "7d4fd5e1d6e6b7a7fe6f337be37585c2ab114131ccae416567c0edf932a35cd8", - "third-party/nsh/crates/nsh-engine/tests/engine/net.rs": "4e8728131a8c0db3d1de097042f7d8a39c33e09bc52ee06b17b9c5033250bf20", - "third-party/nsh/crates/nsh-engine/tests/engine/open_url.rs": "8555c88ca60405d3318d11986902eec0665064d50f0ad3efac8c8f350cd3960d", - "third-party/nsh/crates/nsh-engine/tests/engine/package.rs": "040fecae528688bb22ff52ec6d6f507ca9ac8bfd8ad34ccacdaa844c87ce94bf", - "third-party/nsh/crates/nsh-engine/tests/engine/parity.rs": "4fc77540e938e4f96a996440eff3a01ac6eeda1d33ab140db2ebea4900ecbdc8", - "third-party/nsh/crates/nsh-engine/tests/engine/platform.rs": "e61b88f4e7670151c3e48a4bedb688d99cb1e1013f938cd8581bd300279a82b5", - "third-party/nsh/crates/nsh-engine/tests/engine/process.rs": "198d91e24dff6b6d6c36fd8d10078cef298c92f44dac4a287341a91be2e0b971", - "third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs": "52397a45dffa6ea0f63ed03f818a80d04fb6bf80e89558285a169e3a0ac1590c", - "third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs": "88c89bb234e75070417caecb8261fe3392b40340c549aa1b8bd9d127db558329", - "third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs": "b1ef1a1a99b49294868c0273b492af6cef480acd19f92aba3a1ac354620b1867", + "third-party/nsh/crates/nsh-engine/tests/engine/native.rs": "91e88c0fe5a2414b0dca3af16d28a94c44c40289a7185878a9723dfe6242a110", + "third-party/nsh/crates/nsh-engine/tests/engine/net.rs": "f43157bfde2b9a8b45483e76484c85dd67a789e100f0a01cb913ddfd9df0398d", + "third-party/nsh/crates/nsh-engine/tests/engine/open_url.rs": "eb04fea1cf9fed5fbc911e6c2c48f3d5e83e7e040f6f6542d1099f84f05fe7a2", + "third-party/nsh/crates/nsh-engine/tests/engine/package.rs": "787b661e036836f003693f3b0150ca367a618ea0ed3712f13527d4a8bc4316b9", + "third-party/nsh/crates/nsh-engine/tests/engine/parity.rs": "4358ee8493e053f9754fbde04426108438ea98d853679a82396bdc4966e6e471", + "third-party/nsh/crates/nsh-engine/tests/engine/platform.rs": "c4ecc7f3d0c750f7a6025c62fde04891cd567855b1d089b0def1f2868c5833a2", + "third-party/nsh/crates/nsh-engine/tests/engine/process.rs": "5f4572a07a2e8b687db021f6e991adbfcb589877c0e17c55a9272e20443d8793", + "third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs": "df1a762e4e596dafcb5d2638f0c6c9242e8f9d675753c9eed58b57a59fb77b47", + "third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs": "e2b2bb0b18d9401e7273238820dea8a3584c567d76d4a59fad47392d45bdbaf0", + "third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs": "793d3ac82fd4c181fbd596afe55c4baa44426f2000908f88193d12b4e6ca206d", "third-party/nsh/crates/nsh-engine/tests/engine/rust_inputs.rs": "e6c1164f438884904f0e1613860ba514fb4d0e0f1fc0a920f8750a25e90722e3", - "third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs": "07e9f618058ecf0f0d4e0f9ecde1bbd1d8418436e04e872f59bb514887c05da7", + "third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs": "396c7a84d071a9bba3228a7fd07af0a3366b1ef6d770990e86abf1f91ea35642", "third-party/nsh/crates/nsh-engine/tests/engine/rust_media.rs": "90d67ba62ac3a71cc61bebaf33841f6ca6c2517893918deda0047ff5fba72ede", - "third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs": "07b17345377e7a77ca63442f123e49c0a270d1106b852acbecf4581d6d956c20", - "third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs": "4dbda6dc0a0003b56de9a17e253c626b92de165ce3b2802ff8456eef033ac74b", + "third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs": "d8e703969b5257c938d8c963f03afab807b1124e1de1b155a6753e612cfa8161", + "third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs": "d633989618772f54e29fdff47fffafc49680575c91bb71bd22ba997404660cf1", "third-party/nsh/crates/nsh-engine/tests/engine/rust_renderers.rs": "b3d613d880b1e9e22a1ea539bf59d2b167fde999a555704885f8389946498275", "third-party/nsh/crates/nsh-engine/tests/engine/rust_scrolling.rs": "367633ad14240adbb7d42d69f7779e3fa9fe383bc14c83318b0be25121fbe794", - "third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs": "de866ac09b65bdb07accdaa98e1f00fdd6686546e37b23678686e433e87f7425", - "third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs": "75fc1fda7a04118ef46dc29245daac4c93b517af556591447949a5676c0571df", + "third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs": "cedbafb4d8cdcbd9b113466ecca9a6f20d8b5b28cf6822e8099135fb33c097b9", + "third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs": "197c349942caa5497df1705828386e041983730c843fe6a2e8d0e1b2f60ab9c0", "third-party/nsh/crates/nsh-engine/tests/engine/shader.rs": "f3002aefd78d67f8cd8120104060d37f94e574f4fb17fd347406ec13140c43a5", - "third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs": "6675428cf86502fa9cc09ba5b826723c3ef244d6ef2e2fd85f915770b5d8d1d2", - "third-party/nsh/crates/nsh-engine/tests/engine/split.rs": "ca5139c7ab33fd5c1857a851fb6d91039e27e781667129c5b07540b58a72d7dd", - "third-party/nsh/crates/nsh-engine/tests/engine/storage.rs": "318b4964c56baf3316565319fabba1032fde439eaabb625336afc2ff275f243a", - "third-party/nsh/crates/nsh-engine/tests/engine/system.rs": "426942a83bdb34129d52108d31a064d5e467046d4170c29c3c7f831d348c17f7", - "third-party/nsh/crates/nsh-engine/tests/engine/test_ui.rs": "be6a1dfda0b1360b6a2be321d2b22a9c39b675dce15562867b70a16ebbd340fe", - "third-party/nsh/crates/nsh-engine/tests/engine/theme.rs": "3f05f766f4580d9a16da1293291f95498414f37f3d05df5bf450f0d86504e8a2", - "third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs": "afbbaa2cc69050e9dd0e42a3c2a1193c101ac32237282ebb46b60d5eaf960634", + "third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs": "34529f3d7b6ae26539e2d39149c06de7ebc1d8fe09a0a7e2742b7bc19754d692", + "third-party/nsh/crates/nsh-engine/tests/engine/split.rs": "16b4a8ea7827dc760119ea6bc46502d135d5e424fd18cce5a056731942402a36", + "third-party/nsh/crates/nsh-engine/tests/engine/storage.rs": "1523afe111dec9c7cb78b33d390b8327c9cbb787648872c1868dcd0bb084a801", + "third-party/nsh/crates/nsh-engine/tests/engine/system.rs": "9aa9fd9d0191b7d2262d86dadf2359bb9a78ed3718a7a7f8d20422a8af4496bc", + "third-party/nsh/crates/nsh-engine/tests/engine/test_ui.rs": "c2b8398fad5cdd7b8576260f7d122361a9ca2b3948c275365f142055b79ede10", + "third-party/nsh/crates/nsh-engine/tests/engine/theme.rs": "d84bb4b47ec0f70a5415b4446769883c731c25fa2da4f63876631178273f1867", + "third-party/nsh/crates/nsh-engine/tests/engine/websocket.rs": "c17d2c9dd47486c3db48ae1efe8333043b7c40baf5b1c2d9cdef48c40bd373f0", "third-party/nsh/crates/nsh-engine/unitless.txt": "809f1851136204e4077b75d4795bf4fc4b0a2d33e9f93053a6ea36661b88c915", "third-party/nsh/crates/nsh-engine/untyped.txt": "0e94c66fb47844aa29a7b5deab131001f3ab89ea46e79c1d75679bf618788c10", "third-party/nsh/crates/nsh-ios-app/Cargo.toml": "c1e3ad9e6398953e8fa7df9996304913906fee198d5f9a6574d8c0a01623fc54", @@ -1151,65 +1195,65 @@ "third-party/nsh/crates/nsh-ios-host/Cargo.toml": "c88a580e48ab783aa69177f821243ac159be6e709331b2d30cd9095a3449ed16", "third-party/nsh/crates/nsh-ios-host/src/lib.rs": "65a4ee4672a8a828a8140daed531f2b30153be35308cc83f35150e6644541147", "third-party/nsh/crates/nsh-ios-host/src/main.rs": "5fea860f710b7bd5e515eed8b29a8c14a9280670afdf999f16a31ed03857d9fa", - "third-party/nsh/crates/nsh-js/Cargo.toml": "1a284280a86511cf14fcbdf452a8cf25e527d72f36846d55ec92679a46d227be", - "third-party/nsh/crates/nsh-js/src/animated.rs": "a678e93e82f71ea9757ae65a9f1db40c9f6886a6d12f176db7dd40dad93869cb", + "third-party/nsh/crates/nsh-js/Cargo.toml": "e3297ce39fb5177b26894f43eb782c45d401cf91a1d0f3b02b77b5bd30431a69", + "third-party/nsh/crates/nsh-js/src/animated.rs": "5e821e249f655e70fbde6092d758281635ca48ea543fbdd7672ebcf821ddf9c5", "third-party/nsh/crates/nsh-js/src/bytecode.rs": "dacb31ceb438a23211ad7b7934e3aa9f3c3ab0d256ff1b8cbcb27c7e12aa10ef", - "third-party/nsh/crates/nsh-js/src/canvas.rs": "ec573f9373be992a3f78badd039fb76e256e720c5bccfd3d6d426ac08ab65f51", + "third-party/nsh/crates/nsh-js/src/canvas.rs": "bd13a89515ae0927620d9150994df78f245bfb4eb30adf2cfc9a379a7c7c8e76", "third-party/nsh/crates/nsh-js/src/chunks.rs": "19cd135f1446742cbdd53c16f1c1b2149fc856cc6ae1eabc9f3dc47ae68004f2", - "third-party/nsh/crates/nsh-js/src/classes.rs": "f6ea6e622041cda24e05f3d055d6b32045bac059b8ed3838660291b648ae0161", + "third-party/nsh/crates/nsh-js/src/classes.rs": "48d8dd968a857709b5b512b2d1435e7c711971fcecebaf456938ea4ffbe4e38a", "third-party/nsh/crates/nsh-js/src/clock.rs": "06c083117a5eaa2d2deddcb41e52ab9e1a9f48efa72d6e9a0f27135bd237b161", - "third-party/nsh/crates/nsh-js/src/components.rs": "2c2e8a65c6e29a629ab5d5a328d66f969947a944847276c8d521a182a689c789", + "third-party/nsh/crates/nsh-js/src/components.rs": "1ab788f7e77af0a303096cf73c99494bfcdd8dc029821aa75d9353adb694dcb1", "third-party/nsh/crates/nsh-js/src/console.rs": "2bb26f43b5326680cbaf9ab1fcc58fa0ab049c349feff6dafd39c99d4240f6a3", - "third-party/nsh/crates/nsh-js/src/declarations.rs": "06093d9779964ec565c0bb2e1aee7ac8740c4a435d787e107ccbe7429f413ba4", - "third-party/nsh/crates/nsh-js/src/desktop.rs": "100030730608bc92a8f67bdc7b404b4cfc91cb61485f31fa76659c6f8e9b44df", - "third-party/nsh/crates/nsh-js/src/dom.rs": "ab49e91085c52897de6f7dc76b1234256d4c2050aa0fbb473870d79fbc82782f", - "third-party/nsh/crates/nsh-js/src/env.rs": "c23dbe0fa2feb2983159fff15f0f829f19842393f5d581f524aca7961a7a6ccf", + "third-party/nsh/crates/nsh-js/src/declarations.rs": "6c3a59e2f7d9c0f3dc9d06b87beb1c57335abbe54b736e46d0b3f8849ac1fcd3", + "third-party/nsh/crates/nsh-js/src/desktop.rs": "881616c914ba1488e7f570b8bc4e33384dc6a52e76a5d3f709cb1807387f2010", + "third-party/nsh/crates/nsh-js/src/dom.rs": "736118dbb7524b128490048f40a838a1c5d3350df0fd7c0d13b243c5a0093b0e", + "third-party/nsh/crates/nsh-js/src/env.rs": "c85ac1fd79a6f0fecb4c793fe7ecc2a9952335831e987c83003b551d6026a0e6", "third-party/nsh/crates/nsh-js/src/error.rs": "01a5f911656faa39aa30dbcd71789ce30c35b3e740c5f3ec5125c87b85282bd5", - "third-party/nsh/crates/nsh-js/src/host.rs": "fe8d37250586dda91aea8749dcc8924267a2320c50e7ee948562a5299732bf87", - "third-party/nsh/crates/nsh-js/src/host_channel.rs": "540b147d0fc9b8883d2d4f71d9ef5d703e7e1ccad52af027182856020cfff520", + "third-party/nsh/crates/nsh-js/src/host.rs": "01e255bf965808ed736d91e9a6d19ef54bbc0d67b272b3f69ab8bf522d904466", + "third-party/nsh/crates/nsh-js/src/host_channel.rs": "227196f7a2ba2878a0bb013fba7f356f85bea4c9f82a5c7a28ec0d5407eb7f84", "third-party/nsh/crates/nsh-js/src/hot.rs": "6286e13ee994063c1c72292bfdb44c964dad45b127edb24a2d76461bd163d621", "third-party/nsh/crates/nsh-js/src/input_action.rs": "eb70b0792eff82ccda139f63700656167ac04753872fca2aa63485673d9ab047", "third-party/nsh/crates/nsh-js/src/keymap.rs": "81c64066dde7fc43cb0babdd4c9fa908591206f7db4033a01c5f23b4fbef07fd", - "third-party/nsh/crates/nsh-js/src/lib.rs": "1bd1c002c92afecef6e9540ff69627cd163da700ed895701906beec1a0f9bbfc", - "third-party/nsh/crates/nsh-js/src/list.rs": "4e52a411e30015c83d4a5e41210b868d4fc587ff13c8b90804f29b8e2f48b37f", - "third-party/nsh/crates/nsh-js/src/modules.rs": "302c9fb002da231ed01e3dea9c5782c4ac4c6a4a1849c36370f907feee73377a", + "third-party/nsh/crates/nsh-js/src/lib.rs": "82fee9c9fcea6002cfa4ac052888f98885e8ac6ed9089711bf890309913d8ac1", + "third-party/nsh/crates/nsh-js/src/list.rs": "9ab0342dc14ce531c323b3337dd09d61dac938f3e13b3efec695ef47bbde8177", + "third-party/nsh/crates/nsh-js/src/modules.rs": "7eb2dee007ec80556c214187010e7962a6846727ba41ca0a647703e68eca9230", "third-party/nsh/crates/nsh-js/src/navigation.rs": "b80b6406302399c23d65a6f74ab02880adb0a0f13c536f6a6c7e8ec86aaacb35", "third-party/nsh/crates/nsh-js/src/net.rs": "2c1e993f6e58ecb095a78ceac05357ab747cf8d21769507ebbc3c74c63fe6950", - "third-party/nsh/crates/nsh-js/src/platform_modules.rs": "96adc95110be1515247458cb286e835d75304b4cb94c540306bac8a6ec191c44", + "third-party/nsh/crates/nsh-js/src/platform_modules.rs": "8acc02c6f970510f36c1a95ba78d49e1c2708f59bb9adcabb60f9871ecfd6b82", "third-party/nsh/crates/nsh-js/src/protocol.rs": "fbc0ed3f0104904d54f5c3e98884f0df59519e52f9ff93774042700664beb66a", "third-party/nsh/crates/nsh-js/src/reactive.rs": "36773de3b3e8e1368cb9960fe3925d7d15e746dc85df03c659c55527d7683e67", "third-party/nsh/crates/nsh-js/src/rejections.rs": "7873bcd5daa9b755951b0ded491e72ba9c9a82f77526dfc41df829a998fda7da", "third-party/nsh/crates/nsh-js/src/resource.rs": "52df34d4de5a6b4ab99653f8245a53efcbc7c0e5fda033befd80be16817bf137", - "third-party/nsh/crates/nsh-js/src/sandbox.rs": "b252cee710e81439a630bef44bf042e588047edf45772d8707f213ad7daa4faa", + "third-party/nsh/crates/nsh-js/src/sandbox.rs": "7c97be827ed2d8ed1d4f0b3b5dad9afe56cfe6c50386c15f228b4bd7a8cfdb37", "third-party/nsh/crates/nsh-js/src/standard/bounds.rs": "fb86d8d019ba718a47712679387813e3da25a4767aa489b1f167d32aae1dba32", "third-party/nsh/crates/nsh-js/src/standard/console.rs": "028e5b41914d9ffc478291c9b2410044033532d16b7437f4ac6461b199927ef8", "third-party/nsh/crates/nsh-js/src/standard/declarations.rs": "35923ae3a353872a6c6762b2d71884bd5983d74579a1c16fa35325cf96302c63", "third-party/nsh/crates/nsh-js/src/standard/mod.rs": "06f883f4da4664df2532cf455606b348cd557c3ccff8f7531bd9931b9a3ee463", - "third-party/nsh/crates/nsh-js/src/standard/os.rs": "f7e65e5edaceab1fa982c7080c809a27b7676c4c17e7d641dc9fcdc4484e99d9", + "third-party/nsh/crates/nsh-js/src/standard/os.rs": "16aff8f3b3a5721fd56b4fff3d20d2415a8af523506cac2d66bedba3098ae945", "third-party/nsh/crates/nsh-js/src/standard/path.rs": "9f3d2625abbc33dbf243c9c40b539a0d72ad68ddf21882c02e1ce4e5226f5427", - "third-party/nsh/crates/nsh-js/src/standard/process.rs": "a6256ff9b135d4ea590f07db077224ad849efd23e328785f398c512432b1e36e", + "third-party/nsh/crates/nsh-js/src/standard/process.rs": "c11a750b7f3e212105794d8c0c22a6a81d08eb017215bd01f8cd42abbed140ed", "third-party/nsh/crates/nsh-js/src/standard/random.rs": "62ee219c31d1b8beaff83c6d09e39584b74efa805cc042cde066253c7a9d5190", - "third-party/nsh/crates/nsh-js/src/standard/scheduler.rs": "bc2eb7927a1c11cc2c631c9951da91bb28e50cff3ce66fa2bbceb04ab71e7cb9", + "third-party/nsh/crates/nsh-js/src/standard/scheduler.rs": "2134592b4bf2508d81b7cfb7b6d6687571ee2c3c2ffd02d12dee41744063f2f3", "third-party/nsh/crates/nsh-js/src/standard/url.rs": "3932ebecfac23274d6d06ea62411e6908ca2bb108d7bc823b5120757a4ab2ab1", "third-party/nsh/crates/nsh-js/src/standard/url/browser.rs": "c03fe9ba7c7e8d3e1405393130af19dcbd93cd853b299f16af7f17881856c5dd", - "third-party/nsh/crates/nsh-js/src/standard/websocket.rs": "3a800e85700acbeb0fbd89cda95b6025d4d06984f8552d2413ab7c9056dc3351", - "third-party/nsh/crates/nsh-js/src/standard/websocket_fixture.rs": "f4221f21aed6836d5dc3802831fb29aa1afce699c374706917298368ed518869", - "third-party/nsh/crates/nsh-js/src/standard/websocket_native.rs": "ce208e5d7d3224e58e9ace232a68ef16223809ad4dffb27733f2f3c5bf3f0957", + "third-party/nsh/crates/nsh-js/src/standard/websocket.rs": "2ff33bd12c7a7ef29811bf6a0bead9540b1bd283b4150e18127a8309f4fdfbbc", + "third-party/nsh/crates/nsh-js/src/standard/websocket_fixture.rs": "989442fee770aa687e03d57022f6754248faf6211c9ad1dfabb35f55399b393e", + "third-party/nsh/crates/nsh-js/src/standard/websocket_native.rs": "b455a36c9d0c765fc7c3bd46b0be8db0695cc6a4dc372b3babaf819e4b85e8e6", "third-party/nsh/crates/nsh-js/src/standard/zlib.rs": "25951647e5f4944e7d0666bfaeabf450bf783c8cf1a7e4cf2a9d0861cca22b74", "third-party/nsh/crates/nsh-js/src/state.rs": "1cd0bef0c9198a146b1a509507c6610e357ec8c9425ea4c75ae3391b29f94c54", "third-party/nsh/crates/nsh-js/src/timers.rs": "e88b6fc8659eabe435b5a2b9561d5c8eeb8bf1c765654dd952ee192ee151574e", "third-party/nsh/crates/nsh-js/src/touch.rs": "3c346d5cc1b775f710d8e2e38a3775427ca27112c586fb26bbee9aa93364c212", - "third-party/nsh/crates/nsh-js/src/web/blob.rs": "58f4deb13d6375cb72fa499b749212fe528fb5cb0b484957367cffde93d11b3d", + "third-party/nsh/crates/nsh-js/src/web/blob.rs": "23b18b1af90f61ede79e694cecca85c38199b3f655a9b44dc63596dd501cdb40", "third-party/nsh/crates/nsh-js/src/web/encoding.rs": "b958cc2d58500d6ad1a1cb387e08f972ac42efb87a7d1f041aa89af9b5863b74", "third-party/nsh/crates/nsh-js/src/web/event.rs": "7fd97656bb1844a06d2d68860f52fbe4027bde453a790b10e1771c3d06ead54e", - "third-party/nsh/crates/nsh-js/src/web/fetch.rs": "0cc2f8c455dd86094d761a206692d2d52dbf66e446d25bc16fac4c06c546e0ce", + "third-party/nsh/crates/nsh-js/src/web/fetch.rs": "076aa1a6a239360fbeebce39d742466bed24d6d55d14b346101b54cdee070e84", "third-party/nsh/crates/nsh-js/src/web/intl/date.rs": "5b08b1fece87bb42402d472d74aecf410b8625743a1a9be9b664cdde60776801", "third-party/nsh/crates/nsh-js/src/web/intl/locale.rs": "c0601e395979122307ebbd4fb551c74032e0a496b458da19a402cf1ff66c5d35", "third-party/nsh/crates/nsh-js/src/web/intl/mod.rs": "0ad1f81cdd984f78094cb1d21670d7a8130d92cde627151b7a2fd9ea9661de8f", "third-party/nsh/crates/nsh-js/src/web/intl/number.rs": "eead5bc34c3539d95a0d23e82241c315dd857b42b7e9cb0bdfa287fbc079b10a", "third-party/nsh/crates/nsh-js/src/web/iterator.rs": "b8336670973853dcd6691decd2e7d92682e98e5e693f7dde837ac2988a8271e2", - "third-party/nsh/crates/nsh-js/src/web/js.rs": "9f61c6764cca932e45976e8344564c1c8e0b9334cb06c2180da6c478ea5df854", - "third-party/nsh/crates/nsh-js/src/web/mod.rs": "d875f5165baebc20da7c6c17ca00fe6a4a11287c75e22ce5b8981d2e874c30ad", + "third-party/nsh/crates/nsh-js/src/web/js.rs": "d1b51f30d1d08f671372dae6d1331ad69869998dca3dcfc46b7f1e6ec20355a8", + "third-party/nsh/crates/nsh-js/src/web/mod.rs": "ba3bb76a5791a7e4f83c508814d64d4077efedba18440835ab51455cfddb20f6", "third-party/nsh/crates/nsh-js/src/web/streams/mod.rs": "6f142c419aefd7626a08207e97da7291d9fc993426879b8c5b2c74913904d26a", "third-party/nsh/crates/nsh-js/src/web/streams/readable.rs": "c55d8b6b5a986cd00d68c4d9704bc307ed1f65d619a3a1a29960bbae7ce51d81", "third-party/nsh/crates/nsh-js/src/web/streams/text.rs": "a84c73c597baeedbb8dcf13919026fae6178214726233f09ae272eddccf44e44", @@ -1254,11 +1298,11 @@ "third-party/nsh/crates/nsh-macos-host/src/lib.rs": "639eeb20367559c9d2ce4e4c7936469b3f5e60dc75f6c74eb8f03e54f52152f0", "third-party/nsh/crates/nsh-macos-host/src/main.rs": "81be01e39606c6ffed203ce44e4e5fc2f8a19f320736109b394894272b204363", "third-party/nsh/crates/nsh-macros/Cargo.toml": "a61e435297cb0a1f9948564f43989fed0d04cb1909e73914f6381508a17e3551", - "third-party/nsh/crates/nsh-macros/src/assets.rs": "a8bd25c493d17dae74df3e3e61e16957de11170addac1b135c71a104380d25fe", + "third-party/nsh/crates/nsh-macros/src/assets.rs": "dcc829f8e369d6294711eddeff2707fe04d43c4aab0f9f195f75a0913bbf1e25", "third-party/nsh/crates/nsh-macros/src/class.rs": "ccf3560762a2c3487088659aedcd5d9ddf1b6d4b4bd2d29103694c5ea7cad6dc", "third-party/nsh/crates/nsh-macros/src/host_props.rs": "fc040222d56a5da30b82581b78a1c9f987ffbc03027c54d145a59d97bf32eb84", "third-party/nsh/crates/nsh-macros/src/js_ops.rs": "68d18feb832d72bc417042b496115e8a4244bd9b0be2e27c5f0263af460d2708", - "third-party/nsh/crates/nsh-macros/src/lib.rs": "dcab2fa7777169cab7b31446d9efeb2f242edd440bd069f6227edc1fcd6976cc", + "third-party/nsh/crates/nsh-macros/src/lib.rs": "2ac2cfc8557d06a9de2249012d23994b1365216e465bba5e3efe47bb3ba23604", "third-party/nsh/crates/nsh-macros/src/native_module.rs": "115ce533952a0d4c70d16a1c5509705e14f0bb115a7b65e96042780330d9b328", "third-party/nsh/crates/nsh-package/Cargo.toml": "1402055fce9ba79d288b562e2558626cce216deb3214ecafbf140485ca02ba05", "third-party/nsh/crates/nsh-package/src/keys.rs": "8c4e4c213f53b61815bc5b852deb3b0ffab2a6d83f72d3507b047b20de5d76b8", @@ -1268,7 +1312,7 @@ "third-party/nsh/crates/nsh-package/tests/trust.rs": "48097e716736cb2726e36c1d6913953bdd6a9b6613f39f061e352f3263423356", "third-party/nsh/crates/nsh-platform-android/Cargo.toml": "edd0d6e53a2ed0d9d7ade9d0679d680460dba6c4903470f7859032abeeecf170", "third-party/nsh/crates/nsh-platform-android/build.rs": "0a4329aeb34f85d71ecf149c3b7f6e68c01d9e58186ac73b9bc5f989c3f767d1", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/A11y.java": "9c76576aeb9d0b6ed3ed8efb879cb9696362fa73d338bfa10690e6825bce7a2f", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/A11y.java": "7721ae2f8870dfb8dd400bc298fd3971590317e7e263dc2551703c59d06443c6", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/AppActivity.java": "e017ccf0f5ffd3c781239be6b61161231c22db761dcf8bc9dba833f4f19bc0f8", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/BackCallbacks.java": "ec97d83a492e6c191e28bf890624b291e835ebbdfaf1f5c6ac071e5c56363384", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Backdrop.java": "7e896b364e0caba7e6e6154466a3b3b9ffb32e4998252cbb7c137d5c64111d7d", @@ -1281,11 +1325,11 @@ "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Effects.java": "ffcf8f4a38d23d0c6ecc1c54c0250d0aa8cd06c283fc14360d2c86f0080850d5", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ImageBox.java": "65f575393de7ed59327eb9d1b516972451f74167346d69475924fb6561548f71", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Images.java": "c5ad4e2e34b4a20a3080724a978d8c0304dc012f70aaa9880a1c36f430035834", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/InputBox.java": "db61125a29bf893cbef510cb8d3fec46669c0633092e7677fdd05d446e92c54f", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/InputBox.java": "5c853ba5897211b942ec2b6583255e15b2d260dfe912f35d78974d951665045b", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Json.java": "80d814fa3a0357463394fb0d84d98ec02687abd42b2d3521037db2e1ad20db27", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ModalHost.java": "17f09d42b9d6336f44b6920633d9698c2536ca03f5acb853b5cb617900d67e63", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java": "1ed6b1b0d7160d4bfe23aa032a4c2b08c3d10ea0aea6567903e734784c980d26", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "65525dbbbacdb924678d27ab5488bfea3be8aa912c27a354c12bed6d55a80e70", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ModalHost.java": "442a9126fcfdd6ed911eba82537bb4efff9af2edc80bdb4f7676ecea9b5fdab0", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Mounter.java": "8cfe3e801e18e1c1832356fae53023041ad1fefd453797dd4a09f9b167b97c2f", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Native.java": "fc029046d14f0e8c27bf87829c5b718d32930da1e79909e2252e7b5e96765683", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Net.java": "891adea10cb4f8cf0020ed3854eac241572b00b32506e129987715db2a183149", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Node.java": "d16e8e7d2375e3633c3cff5edeba08cf38d08726801f67721b12ba51bcce4e5c", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshCacheOptions.java": "a374e1c32c99856495535e28acecdc2c3030a3b7e6686416c07fda11b7b25146", @@ -1297,39 +1341,39 @@ "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/NshView.java": "42889391cdfaf39978cdd911d20f4bbf79d54c40f6c677d1f331e451279f516a", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Picker.java": "e327b5fa67b14a354bb9eb09188071f3cfbf03cbbbecd6f289155566f1b403b4", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/Pressable.java": "fe1f771b8e68ffad858d8900e248bcf8005878a61f8610c4dc5c378aa06a8998", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java": "ef9cb5621000bfc0e80e028b7591f6afe7535c207537f96fd77355c2ffb325b2", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SelectableView.java": "a5b61fd1bf899203e475ecddc6d12008558904dc4f3fc2071789a066a80a22da", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/ScrollBox.java": "8907e2bceb310861a8752ad9026a68367646f16a5b8fd8676967c10100361da9", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SelectableView.java": "92d650941c3981ca398f8120431225f013ba4ec3ab61b0bc26b4308d39e44e90", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SelectionHandle.java": "19878e4c4b1a3d0d55fc14087c143db5ff7b5cd11a4ef6dbfd1cae7c3e1b004c", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/StackBox.java": "16c49be8141ab6f03b6eea8bf4ce476bfa2a8e4e16be5082bcbeb1146692346e", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/StackBox.java": "2048113a710ddf3e07135af8440f9e845d265286256df49f68581976598007cf", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/SystemUi.java": "3c9af59c8ae044d103d8120edb77804546e8991d5b2d5c246f66b47003e74994", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TabBarComponent.java": "aa421b235d09565017891a412dfdbad7f414c3bd70d1d65a28eb9dad6f63e0d4", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "4c74179da1d4206b7d5b2c094629994dad15e39dacaaf801a076a29010756a9b", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TextBox.java": "a45980ddcf19ac17f05e39e7d1030b759902fb1fba66a9e49db2bd2cde8cd867", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TextLayouts.java": "fab62752b959322ad552f1de7e27f9213c63c81b496972a6efea3e69b9d9a0ae", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TouchTracker.java": "daeaffd743e6e9d12abcff8230aa11e8ae11c12fea5a2c9555b5d1a7be007d30", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TestAgent.java": "f1067f934befdb32125b301039bba1366407da88c1a466e9fef5a5026f5f8087", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TextBox.java": "b370e532baaf2e1e18e287f8f9dbc777e2f1cc1c5391e8872f4677c77724a0ec", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TextLayouts.java": "5962b529ed7f400f57a2371c81b53803f8505c01a4663fe8b0ddfc791da5c55f", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/TouchTracker.java": "8e2c8573b9a3773532ed762a00385a41082d504030e4a730df6755f6e4db5ec0", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/VideoBox.java": "3cf9bfbdba959c0d2319246dfce444fc575f8b1ba5d76dd429728af22aee1e4e", - "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/WebBox.java": "5f49a4b3748f8df0180ec45cb3b3c296694ca0ba3fdf2c7d117cb4e021b39965", + "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/WebBox.java": "957222449970e05e3226f8185c82829a283dc63c68ec84446af8512a74f69a38", "third-party/nsh/crates/nsh-platform-android/java/com/longbridge/nsh/WidgetBox.java": "51340bdf442b737f2271e091ec2114e0550b005e8a5eba02ad0947c53c574120", "third-party/nsh/crates/nsh-platform-android/src/agent.rs": "c67465629a6d49e13dad3c12bc44550d1d500bcea1222905634df8b39c68b97b", - "third-party/nsh/crates/nsh-platform-android/src/animation.rs": "fbf325210319c1cb413d49656b29c2e720674d7073057f255330c457551d6b16", + "third-party/nsh/crates/nsh-platform-android/src/animation.rs": "e91404766a534c2dbd84a60bb7e29696d73c40901b32dd29e4c926e486cb6678", "third-party/nsh/crates/nsh-platform-android/src/dev.rs": "cd272e1f311288814170c514cc8ffefa8c0a1cce697d661e5fb640e2b1d06fc2", - "third-party/nsh/crates/nsh-platform-android/src/encode.rs": "d738ffda704883a050ea794666e02ceaf4495cfd0135af251aaf61e6e3d86bd2", - "third-party/nsh/crates/nsh-platform-android/src/host.rs": "dddb7c31728d7905026c65f61a93922525cfc713dfcc2e21b2c294cf2bf1bbde", + "third-party/nsh/crates/nsh-platform-android/src/encode.rs": "88a258f72e184a2c14dd1a61e9ae39b8f7ec712de462fcd292675e2f3f810a01", + "third-party/nsh/crates/nsh-platform-android/src/host.rs": "0136884f73394aec20c5c9ff2cd5037e3eba4b762ad3a6eb691cfd58cf3540a7", "third-party/nsh/crates/nsh-platform-android/src/java.rs": "d5818ae5cf45f7e7e78a38d2e405cf56dd9473ca7fdd732bf4c97e6e2c3186a0", "third-party/nsh/crates/nsh-platform-android/src/keymap.rs": "7710df8e27d117d3902ab7788a425406cc6b2b2226f3a42abbc60f16239b4f6d", "third-party/nsh/crates/nsh-platform-android/src/lib.rs": "498c80d7a41a977adde3a134c242b14c783fe2371e305b35be44289559c5cb21", "third-party/nsh/crates/nsh-platform-android/src/logger.rs": "7fe95c849533e54aa753757090b5747c1131e0523f2f5b24f260fb77bd0aa2f8", "third-party/nsh/crates/nsh-platform-android/src/net.rs": "e51b9e715af5652a6d0697beaa707d021935128c06751010e773c0bda9baaa49", "third-party/nsh/crates/nsh-platform-android/src/package.rs": "f69038eb79ac0e6885cf831fce67572e4b068fb164c21ff64abd40c82ef56fa0", - "third-party/nsh/crates/nsh-platform-android/src/selectable_view.rs": "cc941a30eb281cc57d6d516176941b14fdb522a1a8ca20b7ae9178e2b41bfdf8", + "third-party/nsh/crates/nsh-platform-android/src/selectable_view.rs": "d218a70dcf8d38921b2b6845bac6f7e25f028324a3363ca5a7e1bf6c90dc8050", "third-party/nsh/crates/nsh-platform-android/src/shader.rs": "d813f5d52fb387dd4f8db8a936e5069354531bf2ee3439713be70dad4db2367a", "third-party/nsh/crates/nsh-platform-android/src/svg.rs": "72b340449f499586ea54382159c7ea951bf6a96bf6a1ea0b15011e899e6e4f24", "third-party/nsh/crates/nsh-platform-android/src/text.rs": "5c655b1feb5d1e852d5ee73505e60e73080cd09070be51a845940349e50affc0", "third-party/nsh/crates/nsh-platform-ios/Cargo.toml": "90592626b7c4e870e731a056132d9c8a1017134379c560228d6e6a449fb60368", "third-party/nsh/crates/nsh-platform-ios/include/module.modulemap": "687c700460603d8f9ca214ff058f91d0d6edc9e3a7350d981015c73872e5cea1", "third-party/nsh/crates/nsh-platform-ios/include/nsh.h": "60cfadac85c85e03e6ad7c2da269fd340048a727ca531b0d415786e557171ada", - "third-party/nsh/crates/nsh-platform-ios/src/agent.rs": "10eea564b339ebb629599233a87d725592956d6574c6378e9fb4e78e5192c6a9", - "third-party/nsh/crates/nsh-platform-ios/src/animation.rs": "eeceed5e483d1eb3bb895e38d8539f3928e98d8ef4a7c5d3b12016051912b879", + "third-party/nsh/crates/nsh-platform-ios/src/agent.rs": "4c837ec16f595e22aa39211c9d5d6f123c952fd146f1bd69075966ecfd626cd5", + "third-party/nsh/crates/nsh-platform-ios/src/animation.rs": "357a89e1d9cc2792b8ec1c23d2fe53d1ce6347fc72ac0b0cacc3599ced361952", "third-party/nsh/crates/nsh-platform-ios/src/app.rs": "3729a7f4d7bd2fc19239f7cd3615c8264e452bd940e9ebbb8fc0402498e631b5", "third-party/nsh/crates/nsh-platform-ios/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1", "third-party/nsh/crates/nsh-platform-ios/src/bottom_bar.rs": "e1cadb77089951d46bbcd39be553906d2e6025601b133c919fd6a51601d9cddf", @@ -1341,103 +1385,107 @@ "third-party/nsh/crates/nsh-platform-ios/src/fps.rs": "ea6fa48df8b14e3d347b809e3129e3be4c42975d4a694c1e22f4deed00f6e273", "third-party/nsh/crates/nsh-platform-ios/src/image.rs": "220ba49e6ae7284572e28679deaf841a54cb072228c682c10d62d73f8f557368", "third-party/nsh/crates/nsh-platform-ios/src/input.rs": "82a577cad8a175976f8e1bf740bde4e478776285871ed60076648c28a0c5f889", - "third-party/nsh/crates/nsh-platform-ios/src/interaction.rs": "10b85e580a1c507a884d62c222c3f0b115ff82eac0f38e944954e17f9219d0ec", + "third-party/nsh/crates/nsh-platform-ios/src/interaction.rs": "7d59a4d21f7dcd2fca154f4e612c235ff73b0c590e5d7e79c744004547d33ff2", "third-party/nsh/crates/nsh-platform-ios/src/keymap.rs": "f31374fea7f75df5e259b6ff48e546393de01268adba031d6f11a37ce846a29d", "third-party/nsh/crates/nsh-platform-ios/src/lib.rs": "465703423d96cc8abf259a04cb07bec5aa2cf5aaa7743e67e28256c6766cd581", - "third-party/nsh/crates/nsh-platform-ios/src/mount.rs": "e7146471f80c0f70efb313ae21c659b6476baf3c41cd881793e96d629fb5e24c", - "third-party/nsh/crates/nsh-platform-ios/src/native_view.rs": "be046a1386ea4d3bf15ce52fbf5798e636f6ead89bc55a569eb62755934f8b49", + "third-party/nsh/crates/nsh-platform-ios/src/mount.rs": "936279bccb27345ea69a5ca97bb0562c29a59e897129369d7f019738be44ba7a", + "third-party/nsh/crates/nsh-platform-ios/src/native_view.rs": "a210c2ee2332505930ae1b38786b64d5c6cf594525762fb5827e9dd30e3f4423", "third-party/nsh/crates/nsh-platform-ios/src/navigation.rs": "c65a1d6b0bdb0228a7801cec62294fa307d6155131878208286b8ebf7f5bcde2", "third-party/nsh/crates/nsh-platform-ios/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760", "third-party/nsh/crates/nsh-platform-ios/src/picker.rs": "a4c80e7301cd053571ca90318b29d3b6c0defac8520c252f97030d6a5a210eda", "third-party/nsh/crates/nsh-platform-ios/src/press.rs": "b529a8e2c3e39e0bd2e714a1d4397fea3a91d57371ef9cca1c975953f15103a2", - "third-party/nsh/crates/nsh-platform-ios/src/selectable_view.rs": "298d7253cb6317520b80f7b75734f756ad9b89aa75e478add8063b72c3d86a59", + "third-party/nsh/crates/nsh-platform-ios/src/selectable_view.rs": "556c482bf690d42106d0729794b88d3ed1d9899ba6bfbf96b7a0908daf826615", "third-party/nsh/crates/nsh-platform-ios/src/shader.rs": "d74fe175b77eb8b1129cc17dcf6ba9f77a85fa30097d348a5a18e16e9144fd03", - "third-party/nsh/crates/nsh-platform-ios/src/surface.rs": "10e595343f1aea8849e56266ee028425f6dd7952c4a647e37498d447f9ed091d", - "third-party/nsh/crates/nsh-platform-ios/src/synth.rs": "b10dcb8979cd94b56007e636564413d2ea3c32304dfa0cbf770792ac091480ed", + "third-party/nsh/crates/nsh-platform-ios/src/surface.rs": "fdeab7f91d03f898ecb5545d3fdf018f6172505cccd9e9611efb60106f14bf36", + "third-party/nsh/crates/nsh-platform-ios/src/synth.rs": "58d963fbb03bfb6161df221288f63715356fbb830be7f09fa471f022131dfdf1", "third-party/nsh/crates/nsh-platform-ios/src/system.rs": "a069d669a343f45fcaf88935633deaee54be05a58c4a9c8840b4a300a9d241e4", "third-party/nsh/crates/nsh-platform-ios/src/system_tab_bar.rs": "4e4e9c8c2a303d6f016156420dfc7e1d5ca5c9f430a915a6ea8eb1a4658fc449", - "third-party/nsh/crates/nsh-platform-ios/src/target.rs": "b4eba10e962ba81ce6dfbb414d20480bd7088d2f9991cd0c41d6df36034eb9be", + "third-party/nsh/crates/nsh-platform-ios/src/target.rs": "37433a94c781b9a84189a268bfd80c36bd6b15ff05a3d9a9619f6f80942273a0", "third-party/nsh/crates/nsh-platform-ios/src/text.rs": "0b318e203784dc271c8ef368106b7039851ffa585ab032f51e77347270859344", "third-party/nsh/crates/nsh-platform-ios/src/text_selection.rs": "a11e41be25fe8270c389f8137115699eea6bdf8135d4f95e3a5a5a84642cec1b", - "third-party/nsh/crates/nsh-platform-ios/src/text_view.rs": "ea05cea3a368378a725b2064b95b15d343c06199c12fecac8d47d3814423280a", + "third-party/nsh/crates/nsh-platform-ios/src/text_view.rs": "cb2faf5388d2d0099734ff26aa50565ee6e171e29cfd6774570e5809682c8c86", "third-party/nsh/crates/nsh-platform-ios/src/touch.rs": "cd26aabc6353c5b08de8f7dc361af3f4027ed599572dfea17a811e4b4fff8dc8", "third-party/nsh/crates/nsh-platform-ios/src/video.rs": "799decbcd4fd456706aae937cbf77fbcf2e1a6ba86cdf2b88f40bb47c2d3653c", "third-party/nsh/crates/nsh-platform-ios/src/view.rs": "cab49bf66209306b4b3a8c9111283f816463fdf151eeb0015de220066a220bc5", "third-party/nsh/crates/nsh-platform-ios/src/visual.rs": "2b7b58b163c43eb5c120a8840d44f688fd16ec37567a5aa5d0936a013b592512", - "third-party/nsh/crates/nsh-platform-ios/src/web.rs": "2bf4ec4a10212c735585347f72175af0e784b7fe50b7e44037093c48e56c2844", + "third-party/nsh/crates/nsh-platform-ios/src/web.rs": "5482c5f26b7f43cc9758ec1c6fe9a1f3a6e3767f6760bae52212d205938d4566", "third-party/nsh/crates/nsh-platform-ios/src/zoom.rs": "274010fd16832c2c93bde502c2c1d6ad308d59e594cdf859d28d87b5ec6ab865", "third-party/nsh/crates/nsh-platform-ios/swift/NativeShell.swift": "4bb9a32ac32ff1d90e1d48c8c53324f59a21f6f062c6731fb74f6a3a37597999", "third-party/nsh/crates/nsh-platform-ios/tests/navigation/App.tsx": "6dfcd7c59fe38cfb4825ccf059debc72691cb90449adcd9ab4013a9affdbb00a", - "third-party/nsh/crates/nsh-platform-ios/tests/navigation/Keyboard.test.tsx": "46ff02be2e69cc40a66d9b50a1b7179bbc5b2a1741adebe6706080d7841f4589", - "third-party/nsh/crates/nsh-platform-ios/tests/navigation/Swipe.test.tsx": "8854425800d87e91624d4520fcf833d084e384831fa35916f8b30f3f2df7a879", + "third-party/nsh/crates/nsh-platform-ios/tests/navigation/Keyboard.test.tsx": "3f9349a07b8bea5b3c582fa6847d110407fc22d50f27167c09035b60a7c6ca3c", + "third-party/nsh/crates/nsh-platform-ios/tests/navigation/Swipe.test.tsx": "edd88796c1f6b83d51614b3cc3c821fd96169913931aaee74800c128d9d00730", "third-party/nsh/crates/nsh-platform-ios/tests/navigation/nsh.json": "dd92ee74c456d8a057c2923c5d67ecd1fa2062038497ac1485652bdfa0aa7149", "third-party/nsh/crates/nsh-platform-ios/tests/sheet/App.tsx": "c5d47d117a50dd17fe7b0d9a5f24a7029901f71b4fe5845a32f201dac47da6ed", - "third-party/nsh/crates/nsh-platform-ios/tests/sheet/Entrance.test.tsx": "341f4e480f86177c6f827e8ad6da7f170af03c9115131fa520552c96491d6ad8", + "third-party/nsh/crates/nsh-platform-ios/tests/sheet/Entrance.test.tsx": "0a2f41a3b9b24e577b5b2a4e91cb39c9a178e5072dfb530af65bcfa0a992fad7", "third-party/nsh/crates/nsh-platform-ios/tests/sheet/Entrance.tsx": "ea16cc1162837af7dd49deafed7f17b5286b4bf1350ab43bd2d36a0e553bde30", - "third-party/nsh/crates/nsh-platform-ios/tests/sheet/Swipe.test.tsx": "7fcc1f96e81f5e435e8cac2b9074452928dd7cd464512d4ffaf555a1f2a69fb7", + "third-party/nsh/crates/nsh-platform-ios/tests/sheet/Swipe.test.tsx": "4bd7c8b327ebe4aeac969b98ee76e84669c344efa58ee0fdd743b68443516e3d", "third-party/nsh/crates/nsh-platform-ios/tests/sheet/nsh.json": "48b80326d14b862b9965aed4abe24c21e021a0d39a3c6f05a2129104b3c4c8ae", "third-party/nsh/crates/nsh-platform-ios/tests/sheet/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea", - "third-party/nsh/crates/nsh-platform-linux/Cargo.toml": "4b0731377d8fda5cf7516aa982e141929bef5f67610ee9e7de8cc1b7bb7377e4", - "third-party/nsh/crates/nsh-platform-linux/src/access.rs": "b11c440872cc69524d8f49c76287b47222d3180fe8201f5cbb5a04b94b340599", + "third-party/nsh/crates/nsh-platform-linux/Cargo.toml": "84b364862dcc3f77d166895120df0b11832b39e71c586168dfe7ca86a709c054", + "third-party/nsh/crates/nsh-platform-linux/src/access.rs": "34bcd3686030ab66257964eb5a2f5addee38634211460b7f34d05ef49315b70b", "third-party/nsh/crates/nsh-platform-linux/src/agent.rs": "0511b0936e29e47f56197cff06fc33f92f8864b502f5be0835d9e13cc874fd7a", "third-party/nsh/crates/nsh-platform-linux/src/animation.rs": "191c6ec3f85353ce65c231e642cda7df24c5877a4d4cdff050bdba9446c0dd15", - "third-party/nsh/crates/nsh-platform-linux/src/app.rs": "8a80262f7c3d631075ebe665eaa3e4b4d634dfa111055503915d5468f2190660", + "third-party/nsh/crates/nsh-platform-linux/src/app.rs": "04a99bb34834a2ca69a45a8b09a51898b77b56d5d371876fc60cb5d691d62f9c", "third-party/nsh/crates/nsh-platform-linux/src/canvas.rs": "3df74a4a27f66560bba2d92ceed3d6bb0e4731e3bafdf7a0a81280e02c20adad", - "third-party/nsh/crates/nsh-platform-linux/src/chrome.rs": "b0b1ba6b3e5280a5ede9afe7cc629738c1f99b3f57af3a5864c41ca5038e309e", + "third-party/nsh/crates/nsh-platform-linux/src/chrome.rs": "eadd261491fe25fe7f1be41aeee7d9c3a7f46e65547504c7a2bea99cc744a4df", "third-party/nsh/crates/nsh-platform-linux/src/decor.rs": "c52939c2d5dbc4ab141082176bbab067581e29f0e10b953991598a913df10875", "third-party/nsh/crates/nsh-platform-linux/src/dev.rs": "e6c7e4032a9cbb68b48bb44013f8057a66d8d10e6e91b490d048f425d6111133", "third-party/nsh/crates/nsh-platform-linux/src/file_drop.rs": "0469a6e134580a587069a3dcc4f639ef46d298e6f50893e4e0180222ac74bb58", + "third-party/nsh/crates/nsh-platform-linux/src/file_requests.rs": "9074334126a4d8c7ce0c45c10e36570092df9a0c3027d12fdcdac7d77e902754", "third-party/nsh/crates/nsh-platform-linux/src/fps.rs": "3164bfa63c0aa806ec8e9e9a4dbda90733dd8fac3ade4fdcedb1420e618c0a19", - "third-party/nsh/crates/nsh-platform-linux/src/image.rs": "6bb6492b86c528caeaa07d2e6123e7c5a07fb8bfde85f4db5a278bfa87ce675e", + "third-party/nsh/crates/nsh-platform-linux/src/image.rs": "273a02b48745da2a7cd2a0cef9c747d4b2a0e4150b2c1b868574aca40e40a9ab", "third-party/nsh/crates/nsh-platform-linux/src/input.rs": "376e72bb7aa2a16dc5289023d83d1b1662ffefdb6679efba82c391343e876c23", "third-party/nsh/crates/nsh-platform-linux/src/keymap.rs": "f5bf2ea5c79972dfc035a4d473a70b9a5fd8e82a208940ae40d852f6702e5d04", - "third-party/nsh/crates/nsh-platform-linux/src/lib.rs": "3c652327d1c12c53a88f5f0295d40a619e227a14d3c8ac8cd1736f8301bc8d03", - "third-party/nsh/crates/nsh-platform-linux/src/mount.rs": "8c95b334c46d3372d4faab311fb80de5e754a4734183c78bd5fe5d4c51e965ac", - "third-party/nsh/crates/nsh-platform-linux/src/native.rs": "6b1f0cdb318648ed4af8c92b1758f766ccb07eb13d588cb63a239d62f905d414", - "third-party/nsh/crates/nsh-platform-linux/src/native/web.rs": "0a2002679796e48aaf90ffb0d562e190c98bb015dbc62b9199017e98e77c33b7", + "third-party/nsh/crates/nsh-platform-linux/src/lib.rs": "33d31b4c0a5505b4c0fdf48332d2a9f6f74481c0a66f3e6ed84edc8ed93b6f48", + "third-party/nsh/crates/nsh-platform-linux/src/menu_style.rs": "58b4fc47ce4bf3d4d08c07ef0d17282d7ddcc260481c68aea568c918227f46a1", + "third-party/nsh/crates/nsh-platform-linux/src/mount.rs": "0e2184868f29ce08926c731bdd8240ae90224eb21f818902aa40b80bf0efc365", + "third-party/nsh/crates/nsh-platform-linux/src/native.rs": "9e6aa53aba369c278e055e9b5747b410656e9c06b605da822d2861f8443ae296", + "third-party/nsh/crates/nsh-platform-linux/src/native/web.rs": "89cc8a5fe2d83c115009b5a1c5badf39f8e5ce61c090f555a5a3a671304e4cdd", "third-party/nsh/crates/nsh-platform-linux/src/navigation.rs": "e4daa80cf548e27765353f124bd4a5f115857d7655af17befa99cc96453d1a0b", "third-party/nsh/crates/nsh-platform-linux/src/scroll.rs": "26e719d56c0f531d0b4c55bdcc0b5cb2a52190183b48d123d7135996fc8fc571", - "third-party/nsh/crates/nsh-platform-linux/src/selectable_view.rs": "7d4a2d029cc7d06a6d62f5b354c0c2f9e7f3dee170e28d096b8bfce97c9b6314", + "third-party/nsh/crates/nsh-platform-linux/src/selectable_view.rs": "21c65cece06da59f43024793ec4baa0e5a54c2196fc5708afa0fada4d946ee14", "third-party/nsh/crates/nsh-platform-linux/src/shader.rs": "79a1d12959dda0181148bded4c2e634bd2f027d79e6aca33546f51422cbff304", - "third-party/nsh/crates/nsh-platform-linux/src/surface.rs": "4b575d2c5c160e1ace06ad01f097c22de42359f0de953617203eeb469d3caf58", + "third-party/nsh/crates/nsh-platform-linux/src/surface.rs": "d8460c48838b64bc6d6d4e6b7bd7f69e76c70db7d1a5b048bab74332cc47dc7e", "third-party/nsh/crates/nsh-platform-linux/src/synth.rs": "d9f46623612ffe39bd1e7897fd2a17b3c975d95fbfdb4bbed387bceb06028fd7", - "third-party/nsh/crates/nsh-platform-linux/src/system.rs": "bd8a7d3075d4eb646f817e85d0e136ab36cecf8d2b5f532a276f39ab50c0bb69", - "third-party/nsh/crates/nsh-platform-linux/src/text.rs": "c5c2fb690d678bbc930b3ef56f8455c8f8f71ee803990c4a25ff969daf04ee44", - "third-party/nsh/crates/nsh-platform-linux/src/text_input.rs": "50b13ea58a37eb67c335c1d202437f816486a3318856f8aa3610479cd0272dbf", - "third-party/nsh/crates/nsh-platform-linux/src/transition.rs": "64f24b817ba5bf621da4e8fc5005be85d73ba9a834bdf004006846835edf3586", + "third-party/nsh/crates/nsh-platform-linux/src/system.rs": "4ed4e5e2bbffb6b7db09389c2dcf137440d7a51b4fd3e027fc248d2b7900f34a", + "third-party/nsh/crates/nsh-platform-linux/src/text.rs": "f38c0ddb1c616be0d2421409648272fd661f1fb31e452d2fbdac6508af96045c", + "third-party/nsh/crates/nsh-platform-linux/src/text_input.rs": "a93337cdb90e393068f73507072d963313a73876744ec917b9dcaa46b08bc5d2", + "third-party/nsh/crates/nsh-platform-linux/src/transition.rs": "5147130f9da2336ec6b399b5d0da80ac638ebad9814fb2d32df74e3c758bebc6", "third-party/nsh/crates/nsh-platform-linux/src/view.rs": "0fc8ba3acff005c9e317cc062f4969e0a26da704e2154e785553174e1402d12f", "third-party/nsh/crates/nsh-platform-macos/Cargo.toml": "75fe8774373a2cb7962e7d97c5a7dbdd2565fe9da08c39fb56210448bd391162", - "third-party/nsh/crates/nsh-platform-macos/src/access.rs": "95c37bd0fdc4c635ec09ce715be96eefc14f52abcea4bd921e920d403864cb55", + "third-party/nsh/crates/nsh-platform-macos/src/access.rs": "0fa206973750b436c7914cad19916134b348a60fca3af65b7c90ebb91bd42797", "third-party/nsh/crates/nsh-platform-macos/src/agent.rs": "d2527ce30edd77608a23d049dfce4f80b2c6f3aa7c52ad4b1403350c71cc0247", "third-party/nsh/crates/nsh-platform-macos/src/animation.rs": "65ed3bb971317aa92202e694ea8e18f1a3f74ffae52d60a98723309f4ab12667", - "third-party/nsh/crates/nsh-platform-macos/src/app.rs": "23292d6ddd895e2de7c136c52104881f4d777cb4a1128e6aba0b96e87331a341", + "third-party/nsh/crates/nsh-platform-macos/src/app.rs": "764f146a6468c50df0297cbdc4bb54154ceb1025f62c85883682e45b4eed7fe2", "third-party/nsh/crates/nsh-platform-macos/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1", - "third-party/nsh/crates/nsh-platform-macos/src/canvas.rs": "21a3099e8dbf2d0b322b6515f5d5c89a768fb43e6882f076a09184aeefcf3657", - "third-party/nsh/crates/nsh-platform-macos/src/chrome.rs": "ccfaa46925034bdb3387c9ccc6d201e5e4954b30edeea2ab9293115a746d4192", - "third-party/nsh/crates/nsh-platform-macos/src/decor.rs": "292b79516717e433b1cdb702ddf4609fefa8f0c502af964639d289ed180e3375", + "third-party/nsh/crates/nsh-platform-macos/src/canvas.rs": "c0f370a815107bc32dcb46e1742facca8f46fac9a9bc8afcf1e107a1da03a6c1", + "third-party/nsh/crates/nsh-platform-macos/src/chrome.rs": "023617fa18c1ea5c35251b05ced485de0dc2b557034a7ff1394a5ae2394c1199", + "third-party/nsh/crates/nsh-platform-macos/src/decor.rs": "95a22e8678aac2088228842efded6a423c5c3acb449a6f91bd5f2cf995f41435", "third-party/nsh/crates/nsh-platform-macos/src/dev.rs": "bd49bc875c1dad5eebe111881590ac8c56b73e7b14ae01a403f5df0cbbeff125", "third-party/nsh/crates/nsh-platform-macos/src/file_drop.rs": "8cba8f09ee79b5902baa8c1b8b1a7f24d48604ba1eee477421656bffdfd65834", + "third-party/nsh/crates/nsh-platform-macos/src/file_requests.rs": "4da6f58c2a04186eab19d508aaa89863d2ffd1719b8016a5e0648cf82cd4b45b", "third-party/nsh/crates/nsh-platform-macos/src/fps.rs": "1649120d0596c374e5db01ffc309cc6e9bba8da53568c87e6920656e079a0949", "third-party/nsh/crates/nsh-platform-macos/src/gpu.rs": "46e103515db651c57316c351d7345dbc812f513c28d5d0e0489aaf2d8c59d599", - "third-party/nsh/crates/nsh-platform-macos/src/image.rs": "36fed54f01b790ed81081375901933752aeb8087cde67e41a0660d356020defc", + "third-party/nsh/crates/nsh-platform-macos/src/image.rs": "ace61273d7f24e350cc3208afe8aebe472f2576927035dd711a34ce6c9ebcbeb", "third-party/nsh/crates/nsh-platform-macos/src/input.rs": "7c083907d08135478d16768b7a2e51a152f198f65b7dffeac62a52cd2cd6c84c", "third-party/nsh/crates/nsh-platform-macos/src/keymap.rs": "3084b3d0ee332607b2993c9b525f089ea5e73bf0b5f5903938001066e73f4b75", - "third-party/nsh/crates/nsh-platform-macos/src/lib.rs": "642235de1764aeba90c0bcb06c6d62860bfce38868fc1ba9912f86ccd6f0072c", - "third-party/nsh/crates/nsh-platform-macos/src/mount.rs": "4a2f7c53ce76975cd7cafc3e6a2d05d44678ef5e2667be100e5c9334487336a3", - "third-party/nsh/crates/nsh-platform-macos/src/native.rs": "bf4ed8afdca41cf1d5623a68395679797d4f2f897b5cdbfd044e401e03ba167a", + "third-party/nsh/crates/nsh-platform-macos/src/lib.rs": "98b697238d9b0a20448d3956bc4688365c3570f392bb26aeea08e170cca547e3", + "third-party/nsh/crates/nsh-platform-macos/src/mount.rs": "16bf70de75889bfee722eba15f4dfebc725c857be22c8090d767f295cf90922e", + "third-party/nsh/crates/nsh-platform-macos/src/native.rs": "e0354fb81b2a7070bf209f52248599535fcaf437d8408e96eaa5e8be8a0eef61", "third-party/nsh/crates/nsh-platform-macos/src/navigation.rs": "4b65ec9a5441c4288899d9bc1b2c10052e7589bd798337a938d99d97ead44fd6", "third-party/nsh/crates/nsh-platform-macos/src/net.rs": "289365dc5d62b09d9a2da14fc1bcc041cf37f6fed409db24916be830ac850760", "third-party/nsh/crates/nsh-platform-macos/src/picker.rs": "06da9eab5dbced591556f61121b535da1fa47091670a4b67e031db6bffcb31db", + "third-party/nsh/crates/nsh-platform-macos/src/screen_color.rs": "2e27815ed77d3b4de5c15a2f6c7b1c9a8649dff2218daa95505a38994b5a6f6c", "third-party/nsh/crates/nsh-platform-macos/src/scroll.rs": "1e0c30b571c234602515f61ee94aa23f87eae6e8375d8116d19cd04cd3e4705d", - "third-party/nsh/crates/nsh-platform-macos/src/selectable_view.rs": "bb01223e9aced9c0e143be15c4a322ba1886b0f7780f98fdc6aaaac2734121f3", + "third-party/nsh/crates/nsh-platform-macos/src/selectable_view.rs": "24d90dc7db5837229c05c4613a2d6c72b5b9b6a4c53086a852dde4f78adf94cf", "third-party/nsh/crates/nsh-platform-macos/src/shader.rs": "3b2834691ac20a8ae52b121da0d4aba4223b105216a0fa73ef193231d278e5bc", - "third-party/nsh/crates/nsh-platform-macos/src/surface.rs": "c4cb50a5cc8554ee485c195a07662f8210e3805e7cf9e8e5580bfe934bcd1b6f", + "third-party/nsh/crates/nsh-platform-macos/src/surface.rs": "2cfe5ed536054a3d4c2fa877b8b1ad2635273b7b5e11f87284bd8533436e0eef", "third-party/nsh/crates/nsh-platform-macos/src/synth.rs": "29e120eadacb6b62c6de7b66d8a0c6a99d7ed67cb20ca448cc1945f174a28de7", - "third-party/nsh/crates/nsh-platform-macos/src/system.rs": "95a893f600023aadca65ad336f9effcb38075873a4c2c299433eb8890832bfd2", - "third-party/nsh/crates/nsh-platform-macos/src/text.rs": "41a7c5f41ee83cd97bcc1aa526a29c7be487373be78f9c2b494152ae72b93394", + "third-party/nsh/crates/nsh-platform-macos/src/system.rs": "b4cd0bc584ed2bee195c576847ce6d6cf82af2ebf81f7e774af8288d37f7d1be", + "third-party/nsh/crates/nsh-platform-macos/src/text.rs": "2ba04a35f42520710afd6a42177174a1ae948892c3b9ab27692813a6fea6bee1", "third-party/nsh/crates/nsh-platform-macos/src/text_input.rs": "a2be21623992b9a4eeea8034139d465bca8258a948269ae7f332f8f400dce4b6", - "third-party/nsh/crates/nsh-platform-macos/src/transition.rs": "41d697d2faa7c8677ad6e5120c0dd20f54a24ab885d4f3014062615968df60c7", - "third-party/nsh/crates/nsh-platform-macos/src/view.rs": "60bb8c3ba88f16f4387f854d80abdd875a92fb2e9c7135742df133feaae54a4c", + "third-party/nsh/crates/nsh-platform-macos/src/transition.rs": "4cebee36844c69fe9cf6b2787b86f36d0c719e7c8d7e4c95391b9137ce10d248", + "third-party/nsh/crates/nsh-platform-macos/src/view.rs": "983dbbdeedc79259a90180679f0a218ed241a5ce19c6bed79bf1c0151b9de7d1", "third-party/nsh/crates/nsh-platform-macos/src/visual.rs": "6c32d3f286ed8f7fcd0daf45f269f54403e70b773e62ff4c87830575402e6c62", "third-party/nsh/crates/nsh-platform-tui/Cargo.toml": "8757c1cfc5b97419ea8ac3d7502493c123cace9fe61d5998ebd7f7d6d9926e11", "third-party/nsh/crates/nsh-platform-tui/src/app.rs": "fca461bf66e704bbe825088f9b713739f626b30686d487f334253bb6dff82deb", @@ -1450,7 +1498,7 @@ "third-party/nsh/crates/nsh-platform-tui/src/geometry.rs": "1b35d2d302a5fb0e579b02cdc8f1f99b8b2cf39490689b4172f1550e9f5cdc35", "third-party/nsh/crates/nsh-platform-tui/src/image.rs": "267e0bc3ea5e3fcf90c0836b1e1b0d53ac431971f4c9a42bec600d52dfcfc367", "third-party/nsh/crates/nsh-platform-tui/src/image_state.rs": "d1aa073787dfbd91ce75a7b540a010ed3b9b53b87067db1bc8f8e876d8e84c96", - "third-party/nsh/crates/nsh-platform-tui/src/input.rs": "fc3191cc559d1e4e895008cb9ca80a8a820feb28130641cb98257ed9a9bed0bd", + "third-party/nsh/crates/nsh-platform-tui/src/input.rs": "fa2a5b0dfb198a7c5e965f8eb9ca1d8a6f3fba9578f5788622f4fb3b40d5f497", "third-party/nsh/crates/nsh-platform-tui/src/lib.rs": "1abc051686d555de6bb0f517318c73dd91f443fda756ae574e836037e9d2331f", "third-party/nsh/crates/nsh-platform-tui/src/media.rs": "f22b37f75d7f15063a2d755c51a9cc8425db8b22bcb49d011d368d97bbacdf13", "third-party/nsh/crates/nsh-platform-tui/src/metrics.rs": "7ee9cb4f47a068dc9e7873d337138bedcf1b31b205a60df792a94bc5ac04f62c", @@ -1459,106 +1507,112 @@ "third-party/nsh/crates/nsh-platform-tui/src/navigation.rs": "6f67828bb302b0593f38f8c0818857c79752eb50d7ca25d5fb0293d6f8e8b03a", "third-party/nsh/crates/nsh-platform-tui/src/net.rs": "3f8ceb2576dc990ce334adec231bbf8a03410c4058c2b8253e368f751538c133", "third-party/nsh/crates/nsh-platform-tui/src/protocol.rs": "ce736d1a6260f8dedc8b39b264f43f07231c68623a647db016d2b03012b9d11e", - "third-party/nsh/crates/nsh-platform-tui/src/render.rs": "e52f463e011b99311bf112019f152fcaf684608ef12d6e6c760db0475fa446c1", + "third-party/nsh/crates/nsh-platform-tui/src/render.rs": "022b5bc415f669bd6d31e280357d57a786553425fca2a69a176f6a930c4defba", "third-party/nsh/crates/nsh-platform-tui/src/script.rs": "a0fb944fabd26af711306bdee37342b13fdba26c5066e5752e180e4e916d09a6", "third-party/nsh/crates/nsh-platform-tui/src/scroll.rs": "0c59b45bb82899b6f847d922522cddfb79963a405fdbfd947ffcff3b3ba51a99", "third-party/nsh/crates/nsh-platform-tui/src/selectable_view.rs": "b5692ca906c14a23d659dd96bbc7f0e11fe5e3bf67e634b5eebe4ae4e02695b4", "third-party/nsh/crates/nsh-platform-tui/src/services.rs": "880c065312a2b2f964a34e7c161ddc27b476b7a8758143f7a2bf295bab0e40f0", - "third-party/nsh/crates/nsh-platform-tui/src/surface.rs": "11215ae37c2e5083e1c620636da6dea92e3003ad40a9796f8d7607828507c842", + "third-party/nsh/crates/nsh-platform-tui/src/surface.rs": "d9c643a950ab7f37a29ae193f8751d49b1c7698ca8425d8679b86b215ff3b43c", "third-party/nsh/crates/nsh-platform-tui/src/text.rs": "a955c56f01a302d692cf77268b77768ebc20842798ac639e7cca6639ee1c99d3", "third-party/nsh/crates/nsh-platform-tui/src/worker.rs": "0eef61c1cc330015df84793e604d984af732f10fb14e1520c7f989253d354bb5", - "third-party/nsh/crates/nsh-platform-web/Cargo.toml": "d2c3617e83d0dbe9c6ad3bfe35cd8dfa7fb5777408cf21aae853613a9e9ae4dd", - "third-party/nsh/crates/nsh-platform-web/build.rs": "6e60cd4d9737c2766e2631ff775410cc0aca1fc53d8463356ccce49f3a11f803", - "third-party/nsh/crates/nsh-platform-web/src/animation.rs": "ea1e5d08c2aa88c0206d8c29db77dda6a5b986838b766770c26c4e07eacb85dd", + "third-party/nsh/crates/nsh-platform-web/Cargo.toml": "92ba0bf941a5d1659c3aa06295154a0f6aa6b340fd50742977c9067dde2902e1", + "third-party/nsh/crates/nsh-platform-web/build.rs": "2253b1de91d0b437210ffc774265d3aaf99ee632aa35d52c04bbd33c915e0f13", + "third-party/nsh/crates/nsh-platform-web/src/animation.rs": "12bb6d30a230b5cb56e92b7346614464648f07545befcaba4d4469e4e9f790f6", "third-party/nsh/crates/nsh-platform-web/src/browser/animation.rs": "9b7a40f19fe76672f959e40bb0cc5850df88510f8a069104d77f28fa1876182d", - "third-party/nsh/crates/nsh-platform-web/src/browser/bindings.rs": "7b7d6f21bb2a1c67060bdcd34d1009a463c200a59a05cd65047bcd5cdd9ce09d", + "third-party/nsh/crates/nsh-platform-web/src/browser/bindings.rs": "bb085e2084cdf56c12731d623c3b906a366ab854a364cdbd772d2cd1e15ff96b", "third-party/nsh/crates/nsh-platform-web/src/browser/canvas.rs": "6c9d1d464012e1f8aad42f66bc951299bfba0dc54779bf9ba4ad0d64dbfd1f64", "third-party/nsh/crates/nsh-platform-web/src/browser/compat.rs": "ac54b90d241c13d2a92034e02887a0c87c1dda53ed7d2258bfbe148487839830", "third-party/nsh/crates/nsh-platform-web/src/browser/dom.rs": "f5fd3c20f5e922f8061c8d5d7156d526df8dd54a2a2c0fb6d65f110c4e605910", - "third-party/nsh/crates/nsh-platform-web/src/browser/engine.rs": "dc03b50218fa641623cba285f5894cfc4b355db69a1541eba2664455d18af740", - "third-party/nsh/crates/nsh-platform-web/src/browser/events.rs": "eaeec4ffdec988ed5af9356d28b9a09967b600d2092e57c0e8d5d90ca5decf1b", - "third-party/nsh/crates/nsh-platform-web/src/browser/fixtures.rs": "632a7dea497358e1b1d65bd4c2d4d33a68f240376ac681852770bede6905af55", + "third-party/nsh/crates/nsh-platform-web/src/browser/engine.rs": "c207597e40ee02ffc7a252496677eff232680af738aaf57ae91bbe70da98f663", + "third-party/nsh/crates/nsh-platform-web/src/browser/events.rs": "ffd30c602346dbd0e8716ccd0d640acddbad1e201c3404c5e42d0be2e0f14ee9", + "third-party/nsh/crates/nsh-platform-web/src/browser/fetch.rs": "ccb6596550601e95622c5d593d73a95bf04208fec0f52476478d944ac6b25b8e", + "third-party/nsh/crates/nsh-platform-web/src/browser/fixtures.rs": "5cc6d8b2baf6bf67cdff92dfa27178dbf47f26b0d253c969fb52b3a2c0ade02f", "third-party/nsh/crates/nsh-platform-web/src/browser/gesture.rs": "78471fe66e728bf564f4226348c73fd2044916d285c02b54302d79578188ad5d", "third-party/nsh/crates/nsh-platform-web/src/browser/keymap.rs": "38ff5333e3287ffa4cc42a51987052f4812d783501c6f2e099864d57cae43dd0", - "third-party/nsh/crates/nsh-platform-web/src/browser/mod.rs": "37ec0cbe5ad3ebdf74e84dce4aed22a545f7264794db8f7bbeaf096119e20307", - "third-party/nsh/crates/nsh-platform-web/src/browser/modules.rs": "cdf5412691a20fdd3820bddfe712404b4b303890f0d8af9f5b6736302dcf48fb", - "third-party/nsh/crates/nsh-platform-web/src/browser/protocol.rs": "1be2c29eb8cbb1d738e8e56a043bf33abc8d028366cb8b0b46c3fb69ea638536", - "third-party/nsh/crates/nsh-platform-web/src/browser/reactive.rs": "2cb447b33dcabe0911cc6808189b0e06db69268c22ca79146a7f11dd89ac5157", + "third-party/nsh/crates/nsh-platform-web/src/browser/mod.rs": "10bd9d9f4355ea5399ea42a8800ff60838efab7c9bbfe12a64a82c8690e3e66c", + "third-party/nsh/crates/nsh-platform-web/src/browser/modules.rs": "0fa9be81936805a1a2789bd6012bf71ae4a91ff9c2e9ce820ccf4f1084af7a42", + "third-party/nsh/crates/nsh-platform-web/src/browser/protocol.rs": "40ac908bb3c57ab8e8a6c28b53da3763624f9676de8a07d7bd62a619da0b67b4", + "third-party/nsh/crates/nsh-platform-web/src/browser/reactive.rs": "d53e0a3a276227c8c5568c74700a6bd2827898a7656ebf042957df572d2ff3cf", "third-party/nsh/crates/nsh-platform-web/src/browser/resource.rs": "85f1d54445d957dd63d0a54ddcbb711869d4e63b367fec3b287173a108141139", "third-party/nsh/crates/nsh-platform-web/src/browser/rows.rs": "afcdb9e2c5bd1dd612b9679dd5e94a50ca636e1326b3f98a13e3bbe8f1968df4", - "third-party/nsh/crates/nsh-platform-web/src/browser/standard.rs": "1f29371bb300f55373f277c99d88cc61bf09f9ad43d140ea8569de427d3cb9eb", - "third-party/nsh/crates/nsh-platform-web/src/browser/values.rs": "f4ff7ba4605ec0a3960d7fcdf882547b11b794b6d10656716dfe02963791ae32", + "third-party/nsh/crates/nsh-platform-web/src/browser/standard.rs": "b2ec90ca952b63667fc909a566bd79e14ea1f517e5288f6d954a39d017ef8074", + "third-party/nsh/crates/nsh-platform-web/src/browser/timers.rs": "b7da40f90fc56bc5454d38be0ec93db944b6fd01701d537f47a85d4183899a00", + "third-party/nsh/crates/nsh-platform-web/src/browser/values.rs": "ac126ed6c9bdfcd317f6696ed16c398777a37fd3b96aebf721a60eba13484cef", "third-party/nsh/crates/nsh-platform-web/src/canvas.rs": "f7f9bfc0dbf6da0d22da19f3b0402f4f24033032b334fca521a2b65efab35ac8", - "third-party/nsh/crates/nsh-platform-web/src/encode.rs": "7d6211432832365846565756335ccbca7535372707b4c39e4ab6b7914bae44d1", - "third-party/nsh/crates/nsh-platform-web/src/ffi.rs": "0bb74d530f282254a88016dd08172067bd945746d03b44bf72ce3622b1ce23b3", + "third-party/nsh/crates/nsh-platform-web/src/encode.rs": "f415c04eb6aece631562a52098116861a74842433a0056c11d1396a47c861134", + "third-party/nsh/crates/nsh-platform-web/src/encode/input.rs": "dcab13ccf1424ffd625109b5607454bc2205e444aa791288b10e351b3492823d", + "third-party/nsh/crates/nsh-platform-web/src/ffi.rs": "ed217c9641c69f68c6a81d2b4dd95849f1b4823b9082428baa245ae1258c7774", "third-party/nsh/crates/nsh-platform-web/src/filesystem.rs": "5dc61372627090ada69d1f4391bd3ae413a7a0479fa566e5b6d5b58b7a872bc5", - "third-party/nsh/crates/nsh-platform-web/src/lib.rs": "c091c6581569cf234e34c7ee5b7397bdd6a83a0983d165ed8a76fb6f320359a9", - "third-party/nsh/crates/nsh-platform-web/src/local_time.rs": "63f2781b1a5364c3914bbefb1bf0538d77876a1324a2e7a9548231711b8241f4", + "third-party/nsh/crates/nsh-platform-web/src/lib.rs": "4ebfc8c4d00578c5c4c52116353b206f7e00c25b4c1e4c654250a2690f67d71a", "third-party/nsh/crates/nsh-platform-web/src/net.rs": "6ad23669ffb7f920da1d4c0acd9cdf6bf6a1b8f33317dfd27eb40ce0b133fe9e", - "third-party/nsh/crates/nsh-platform-web/src/shader.rs": "8548674f31f931ec9c126020a4863cba63969b78f77d094370038894402af05b", + "third-party/nsh/crates/nsh-platform-web/src/shader.rs": "29877a892a905fa45e42c44a961da2ffa43fd8bc511bf23b3cfbb59a7ca016ad", "third-party/nsh/crates/nsh-platform-web/src/storage.rs": "fdff261938b0d75b6b1ff633b4788cc386de23aede2b563fb226508042a3c27a", - "third-party/nsh/crates/nsh-platform-web/src/text.rs": "a7a21f0242090ddbdb948acad7a692056d48f1e653d8dd6a3c3b376bb27a19ae", + "third-party/nsh/crates/nsh-platform-web/src/text.rs": "5d3bd8e0cd340614941a3b86842d0b7ad817d87bf9b859f1bcd1516dbfe72cd8", "third-party/nsh/crates/nsh-platform-web/src/text/css.rs": "633b8d08b527dbb6f63e6e021922d6378f29de173167809bf193783e8de5843a", "third-party/nsh/crates/nsh-platform-web/src/text/hit.rs": "c27ad64415f996453cf07f88c0b611bd3b6f991a1488d4494abf85881ab5d7d7", "third-party/nsh/crates/nsh-platform-web/src/text/wrap.rs": "0646debcea68c12f9b13ec4a50c16dfb2f3484eb897c6ccf80a72d36a199ade1", - "third-party/nsh/crates/nsh-platform-web/src/websocket.rs": "4dcbc4ae65c9d8d8833eca7e2117be425241f28ff17cd2e15dba32b7f6a0fdbc", + "third-party/nsh/crates/nsh-platform-web/src/websocket.rs": "51da3bdd079798a73418257efac53f55d73b36fd74b280cebe494d97992dbdad", "third-party/nsh/crates/nsh-platform-web/tests/text/fonts.rs": "52ae86601484f6f18496b754aa470bd379dc49e94148ec558a08fdd6227dd03d", "third-party/nsh/crates/nsh-platform-web/tests/text/hit_testing.rs": "c0e286841cd154cd686c514cc89ad2f99eaf838d1d24a9b72b537b3934290b1b", "third-party/nsh/crates/nsh-platform-web/tests/text/lines.rs": "d3fb891b46210453a64bf970800d1680920b9a1684bfc99f7e2d9efde5134b41", - "third-party/nsh/crates/nsh-platform-web/tests/text/main.rs": "519af6ec3c919ef162de4f45f3477f18befae5071c65dc05850425ec41b72a17", + "third-party/nsh/crates/nsh-platform-web/tests/text/main.rs": "740f1e736f4e9ead7955b4c15c2174a8d6fe12f8986a551978bc10b6802ec897", "third-party/nsh/crates/nsh-platform-web/third_party/browser-buffer/LICENSES.txt": "b9857f4a8484cecc48bf69dca6e6c386211ffa65f267152718318fc9bd7016bc", "third-party/nsh/crates/nsh-platform-web/third_party/browser-buffer/README.md": "d1b956e92b17f6cf5c77f07a75431634df0253420ff257bea4738e32c0a770f3", "third-party/nsh/crates/nsh-platform-web/third_party/browser-buffer/sources.json": "7b750c9a2a8566dd6a65fc13630ee94f2d43549367f1320f1fb79f9e78636fc9", "third-party/nsh/crates/nsh-platform-web/third_party/browser-path/LICENSE": "a22b9d5763f574e5db347c30acc0b33eaf4846767c03d2e27d012e864e79a824", "third-party/nsh/crates/nsh-platform-web/third_party/browser-path/README.md": "7420de6aab23b451cf603b930fc0eb8fe0af5d4565f43070a9a84b686b03f62f", "third-party/nsh/crates/nsh-platform-web/web/browser/LICENSES.txt": "4e1a13bfcc00067bce795245609a2bc4abec93b02c4c55b71b1d867583b94ba0", - "third-party/nsh/crates/nsh-platform-web/web/browser/animation.js": "7764d5860fc44b6c91c1524b7cf3a7a0f099e86afa35e3ff3e966ed5deb8d6f8", + "third-party/nsh/crates/nsh-platform-web/web/browser/animation.js": "8c76c39dd46ccf0be14fff717c1617248b6f83263a3ec48f0d26239a41aeac05", "third-party/nsh/crates/nsh-platform-web/web/browser/bootstrap.js": "1fb493e79ed7dc8f7f041525aae2ec41b29eda96753428489ac47e4348062061", "third-party/nsh/crates/nsh-platform-web/web/browser/browser-check.py": "132c5742309b26bc2538d7c9b5a5109eb1808cba1b420a044bbcdb1078e72b1a", "third-party/nsh/crates/nsh-platform-web/web/browser/buffer.js": "1fd19073655b60e681e1d0d795b457206d961cff81e436d37b7897a762847245", "third-party/nsh/crates/nsh-platform-web/web/browser/canvas.js": "9805c02e8d65f9808eceede26a01a364d68e828a5c7bba62186c4ded532220d5", - "third-party/nsh/crates/nsh-platform-web/web/browser/components.js": "14a06784ac1ea70478cdd916bad5e008c3db9f3a063120260f87f7bb9dae987a", - "third-party/nsh/crates/nsh-platform-web/web/browser/dom.js": "2b150249003cf599c38781a638e2cb73e398370bb869ebbd18c5dc66ed5b321a", - "third-party/nsh/crates/nsh-platform-web/web/browser/environment.js": "6190fc4a22d2bed78bdc2e752816b4860588cd3917031eb52b8e9b5e53065d07", + "third-party/nsh/crates/nsh-platform-web/web/browser/components.js": "f157eaf4b183da2d8e96ec21393840094280535bf81619075901b4a1fdb49b72", + "third-party/nsh/crates/nsh-platform-web/web/browser/dom.js": "00cc97eccf100cd0fa1a72bd2ab265d7d64f5b604adf23bdaf6da0f08f67df08", + "third-party/nsh/crates/nsh-platform-web/web/browser/environment.js": "02825e9867b42e7d312b7f5328b88a58f81632903ef9e82009ad7bf51cb724c7", "third-party/nsh/crates/nsh-platform-web/web/browser/gesture.js": "79ad38215f147d0b7a2f6b20bc3983e18637ee6c2e0d67d19104a2a92255ca3f", - "third-party/nsh/crates/nsh-platform-web/web/browser/host.js": "043a7883d49115a024574dbbbcf15ee2aa08d1e6ca31f53a75170b8bab79e8d7", - "third-party/nsh/crates/nsh-platform-web/web/browser/keymap.js": "ed0440c98d50ca93990ed6448421b65e327d8f90bdfd8ea0d6f3de37638170da", - "third-party/nsh/crates/nsh-platform-web/web/browser/list.js": "fe2002195a043957383d505bce9c658d572be6cda72ff03992751165333cea7a", + "third-party/nsh/crates/nsh-platform-web/web/browser/host.js": "56975a1c597bc29716b8188032e1fa0e71430c4aa40a315c2c940061c1a6361b", + "third-party/nsh/crates/nsh-platform-web/web/browser/hot.js": "2e97acb14996bb87585d60abdfb581180724e3e6db24a7fbecb69bfa50854fcb", + "third-party/nsh/crates/nsh-platform-web/web/browser/hot.test.mjs": "f69bba469d2873fc72873dff556c61ec08e6fcd56356e3c678c97939c7f6f458", + "third-party/nsh/crates/nsh-platform-web/web/browser/keymap.js": "13841fc54510ae82e3730e58cd9a2bae4136ed07b3604979e5aa5461b009c3a3", + "third-party/nsh/crates/nsh-platform-web/web/browser/list.js": "fa083eedaa6e45a4560224d2e7b2d235e08366d1c5d121b08bb16a8d77019850", "third-party/nsh/crates/nsh-platform-web/web/browser/loader.js": "5db9fed951593fb7fb83ee0895c8e2f40b99b3793b2a4f421e2f02f7929e5309", "third-party/nsh/crates/nsh-platform-web/web/browser/loader.test.mjs": "e112909cfdfe4728a5a40c5964457da3494b5357f540c0fe7a2ee5c3ecf14e8c", - "third-party/nsh/crates/nsh-platform-web/web/browser/modules.js": "d811dcc5fbca57cf40ec08c33a9536a85520178de77bbd090c074ab5690b9d9f", + "third-party/nsh/crates/nsh-platform-web/web/browser/modules.js": "94fcb060a0f2c2db7d4d22f1918c1cdfecb46937aae3d346abe0332d9d8d0d7e", "third-party/nsh/crates/nsh-platform-web/web/browser/modules.test.mjs": "ee004641891bacbc861be8e1a1b9da997caa1963fffe39b6a0760e595a0af5cb", "third-party/nsh/crates/nsh-platform-web/web/browser/navigation.js": "e49d61101447abb8a7a372841e663474589188bdcef32e51e335246ce71cfd51", "third-party/nsh/crates/nsh-platform-web/web/browser/package-size.mjs": "af5d6e7801c9a2b7b0391bec7e9f43f2878f995640b134fda8fc8fd9b70aaed4", "third-party/nsh/crates/nsh-platform-web/web/browser/packaged-check.py": "3429b15431cb4c15fabf7d2c0a8ce0f2d8f24f1d64a6963ba2fcbf9fdef2d765", "third-party/nsh/crates/nsh-platform-web/web/browser/path.js": "d0982f6e4cc96b5b431de752a400b5af2645c3f696ecfaac7ba6f162f0c5016c", - "third-party/nsh/crates/nsh-platform-web/web/browser/reactive.js": "abb91770ec12bc74f6f8cbd1e95ea577f669cbe3e05e5c0307dd7eaa43960b64", - "third-party/nsh/crates/nsh-platform-web/web/browser/reactive.test.mjs": "0210bd102f7d1964fc8b4549e77237a5f322b9b33fb3d9b2f45a2f2d53d96766", - "third-party/nsh/crates/nsh-platform-web/web/browser/realm.js": "e99f510f82fbe1ac814c653e11199309722149400b2ecb3947e57543b5191f43", + "third-party/nsh/crates/nsh-platform-web/web/browser/reactive.js": "5a3fbda03a73e0c3bdfa664b6adb8238dd75219a5d6b76436157a84a0c19520a", + "third-party/nsh/crates/nsh-platform-web/web/browser/reactive.test.mjs": "cd127881bf0d1829437f65d715c1be55c5b868d3327b476d577773956c658cf0", + "third-party/nsh/crates/nsh-platform-web/web/browser/realm.js": "f2efabb6dd0395842ccc1400a620b93761cec3170a736e8102038dcc108e141f", "third-party/nsh/crates/nsh-platform-web/web/browser/resource.js": "47deaa0d1c93741932dba765b6d9218653376044ee1625cb081b1a7c0cc502ae", - "third-party/nsh/crates/nsh-platform-web/web/browser/runtime.js": "15eadf6d42bde56e9a168ff8655242278e16721a0d9bf67955a18839a4652d02", - "third-party/nsh/crates/nsh-platform-web/web/browser/scope.js": "ded056667bce3edb0191078ba8712625b2d31a6d274b4cad2a63060916a992c8", + "third-party/nsh/crates/nsh-platform-web/web/browser/runtime.js": "a24c7c14b5653adc2328dc131fb14692f92e8152e5ac1ab70eab4110a9a0cb9c", + "third-party/nsh/crates/nsh-platform-web/web/browser/scope.js": "160c93693bcd4dd6a08560cb80bebc8271cfe8817ba00bc765f4c4b9a6bf39aa", "third-party/nsh/crates/nsh-platform-web/web/browser/standard.js": "59ab9599830efdd1443a193f9215512e6c7a1def7c74117aaaf883df2b30e465", - "third-party/nsh/crates/nsh-platform-web/web/browser/values.js": "ca2dcff16969fff8522bf6d96f7e786a2266dfc66c235807028211a0b61029af", - "third-party/nsh/crates/nsh-platform-web/web/browser/values.test.mjs": "0bfe115ae3a252410ca7b281d893e73cc3774f1d4b6aa117a968d8e31b199898", - "third-party/nsh/crates/nsh-platform-web/web/index.html": "8a116521f9839ac47b85c15a231ff1dd4fe676ae3d0214078b7634bc74cb35ea", - "third-party/nsh/crates/nsh-platform-web/web/local-time.test.mjs": "e8b62dde3208d979eeb281bad19fcb7c19566732500e969ab2ae5c4cf373c9b2", + "third-party/nsh/crates/nsh-platform-web/web/browser/testing.js": "b9b94ff257930e0eb9d747a9c97cb74e441aa83c3259823054c8301f45f5e152", + "third-party/nsh/crates/nsh-platform-web/web/browser/testing.test.mjs": "08d5e727856816d592e7e7838d895483e595c9e984a9290ef03589930c27ac62", + "third-party/nsh/crates/nsh-platform-web/web/browser/values.js": "008b9df765c0a01e971ba5473f04c06b3cb77fa536fe5cc0ca39ae5ac6d3e94b", + "third-party/nsh/crates/nsh-platform-web/web/browser/values.test.mjs": "92cde35045fb5043eaf9fd733defc66fd475fed9f0a5f5109f2e8b1c0d30ba6f", + "third-party/nsh/crates/nsh-platform-web/web/index.html": "a24854fb7e532855b9547f0aa1b94de409da6902604e9fa75640b9982afdb940", + "third-party/nsh/crates/nsh-platform-web/web/local-time.test.mjs": "56b6cf30f1e0db942850e986fadbdec4e1c259ad46af58bbbebf6ddfa92c9adf", "third-party/nsh/crates/nsh-platform-web/web/mouse-browser.test.mjs": "a7fd637ded0753a220a06fcc57fe0922ff18dc87199887d518aec0e99809d556", "third-party/nsh/crates/nsh-platform-web/web/mouse.test.mjs": "a594eeddf9103c6ae5aa294fa084ba416f600af5853cd77954572f5b608cf2ca", - "third-party/nsh/crates/nsh-platform-web/web/nsh.js": "2a0899e232dd3e6029dd8968392843bd9bfcd8e8b45301e57d8d7b9b191d05dd", - "third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs": "c5b3303025a0ce8425a77fe455f3c5abab3faee53dd0e666170a2ff9e877f4b2", + "third-party/nsh/crates/nsh-platform-web/web/nsh.js": "5245ee7869d77fd519aee43ac29585c58a4c1cd8090b4f36475d0769e31707b0", + "third-party/nsh/crates/nsh-platform-web/web/nsh.test.mjs": "f1f93d4ee46a7b39b49b8013ea105625d86cdd83f9ac09864d5f0aae6593531f", "third-party/nsh/crates/nsh-platform-web/web/scroll-browser.test.mjs": "3f8150ebd2b69d7ab306307868b938b99435308ff37c2c999cf297a0f94fd30d", "third-party/nsh/crates/nsh-platform-web/web/scroll.test.mjs": "8340b2dfe118ca654c0fc3313331aae9098288f777d49811f0c5cc1e97ab2e02", "third-party/nsh/crates/nsh-platform-web/web/shader-browser.test.mjs": "503f9d39585ac3cc1319f6937b559512676f3fe12eb472c7a5cf04c8650ce051", - "third-party/nsh/crates/nsh-platform-web/web/text-browser.test.mjs": "d81d505db937f4f46f9a3e09bdfe7a9b60d590158b161f3013868d6b796de08c", - "third-party/nsh/crates/nsh-platform-web/web/viewport-browser.test.mjs": "ff123920cfd90b6ecc7f295a4b26c5a329fec8315b86d1b089cce354a09dc365", - "third-party/nsh/crates/nsh-platform-windows/Cargo.toml": "0e8ecf656f13e78c83760ec00a410d6d51dba60f0e8edda9c0fedc0c8ad3f089", - "third-party/nsh/crates/nsh-platform-windows/src/access.rs": "ffdf3d2201047335ab495e1808f644ffe4cbd887e5da8ecbb9f74f09f208e261", - "third-party/nsh/crates/nsh-platform-windows/src/access/uia.rs": "026189028e4ab0ed1e80f80e9e707c08ec4d50e411c0347584c88b40255f1c04", + "third-party/nsh/crates/nsh-platform-web/web/text-browser.test.mjs": "d521e2ed9488f1524b6a371afd7e52e7bd97de857520041d88265200f02eb87e", + "third-party/nsh/crates/nsh-platform-web/web/viewport-browser.test.mjs": "9e424aa21c11447fb61275f65f1da4f4d530eff0031bbf96755e54ec019460f3", + "third-party/nsh/crates/nsh-platform-windows/Cargo.toml": "1fae701ea2e1faa26f9179e32a6010b9b85b3907ef8b05f9b61085040d4d1840", + "third-party/nsh/crates/nsh-platform-windows/src/access.rs": "691ac4aedbc2b589d95ecdc08793b33fac0d852e94f7ca2e4150850d22924081", + "third-party/nsh/crates/nsh-platform-windows/src/access/uia.rs": "4f4d9f6f5f51a26a3d49935f07383b7adcd5ee8d527762d1104083865c73a42f", "third-party/nsh/crates/nsh-platform-windows/src/agent.rs": "67a38eea5e61c9c7c0831f7e9b6b4ffb8cd544ede4eb0ab621f35a5d719833a1", "third-party/nsh/crates/nsh-platform-windows/src/animation.rs": "eca3121f02c97d815c8ef98ba26b37eb8fa6ecbe4af1b98dacd4eab6a09f2682", - "third-party/nsh/crates/nsh-platform-windows/src/app.rs": "9a1a8ee477e8a493b5574932ca452272bfc1e2d3541123a7ac7f111c035a42b6", - "third-party/nsh/crates/nsh-platform-windows/src/canvas.rs": "969c45b31074e42b433503f6dcce6e1b80ae304b427354c76e4ffdf9d2b52135", + "third-party/nsh/crates/nsh-platform-windows/src/app.rs": "53572c9d246382f70836a118e18dc17515777b618ba5ffbde165298eb0fff753", + "third-party/nsh/crates/nsh-platform-windows/src/canvas.rs": "40f99efadcfd1a8f97898896dbf7b26d90d4c615895affbd7ac8b11393198aa2", "third-party/nsh/crates/nsh-platform-windows/src/chrome.rs": "1d7cddfb2cbcb9e6fd0c02e4ff72ee468caf89871142c3a6a8ffb5207e9717e0", "third-party/nsh/crates/nsh-platform-windows/src/compose.rs": "fb996e3c8fa97934361aa72c27f599255f242fc98c79ee1c6226b4ee94c314e9", "third-party/nsh/crates/nsh-platform-windows/src/decor.rs": "366368395eafd52a3658ff5524b8027c55dc2abf42bb29d351499ef730b0ccde", @@ -1567,75 +1621,84 @@ "third-party/nsh/crates/nsh-platform-windows/src/decor/layer.rs": "971afe6bc036cc45d6cb951acbe2129091de320228aa058f3a7f9c97c4693401", "third-party/nsh/crates/nsh-platform-windows/src/dev.rs": "1562c98b71e2095c60ab155bf180cb10149c1d9d34402d3be20b2d42f5765954", "third-party/nsh/crates/nsh-platform-windows/src/file_drop.rs": "2cf5961a55d06cc0c08036125e7cbd387cfc1bcebfd30d0344d37e7db05fb63a", + "third-party/nsh/crates/nsh-platform-windows/src/file_requests.rs": "72536bee7b7cb9a7c3623a63b7aaecdb5a69958e69198ca0dc10a6ac5a85c932", "third-party/nsh/crates/nsh-platform-windows/src/fps.rs": "ab1ea0b5431df52bd909c1b14bae942b0d33fedce44b5c3ba97d9f81d779eee1", "third-party/nsh/crates/nsh-platform-windows/src/geom.rs": "f545bac8767818486dced6f12fbb4cd2fee00114dbbdf9bd77c610268cc9d4a1", "third-party/nsh/crates/nsh-platform-windows/src/gfx.rs": "a2372440ec675d4266d233712ff8b9e5c8b4172b7a80865f5743d1c1daf1e0fa", - "third-party/nsh/crates/nsh-platform-windows/src/image.rs": "fb7779326d58c746d4818b1ad5489db301c6c834cfffbba11b6373dd1501c7ea", + "third-party/nsh/crates/nsh-platform-windows/src/image.rs": "740395a2eac20eb6c4227ac926b01fce31a27010c8d4d5dabc15ba1ee8198176", "third-party/nsh/crates/nsh-platform-windows/src/input.rs": "8149ae9091b54862f0500c1ecfb1acf6e6d44f1ea602164732ad6d4a3e1f52ad", "third-party/nsh/crates/nsh-platform-windows/src/keymap.rs": "2dc3b8fa2ba316dfb5c884d9b3a017af6cc1ae23edd31da019323a2ad80e1132", - "third-party/nsh/crates/nsh-platform-windows/src/lib.rs": "fbf3b525c3c86a61204433939aa880cc8b53628f0d01c38bfca86a17bd6bf820", - "third-party/nsh/crates/nsh-platform-windows/src/mount.rs": "8b92e25d091327df10711f5c42f49dcf78a7f2875f7f8940a7dcc3109a47751e", - "third-party/nsh/crates/nsh-platform-windows/src/native.rs": "5bd1e9432de5c33299159ab22072f0a61fc3965fee38c5ac2ee4487049a189ed", + "third-party/nsh/crates/nsh-platform-windows/src/lib.rs": "fff1ec2a94ad9fb3b3aea6443c23bc2edcb0cac929b0bc0df714e29208785b4d", + "third-party/nsh/crates/nsh-platform-windows/src/mount.rs": "7a3fc1285e90df16c35bb6ff274aa2e05e68dad7686ee30a862147d91b995ec4", + "third-party/nsh/crates/nsh-platform-windows/src/native.rs": "679205fbbaf4b45a9ffde8410b9e6a6b1d032f3a125a229e384b3ccdfeadb1d2", "third-party/nsh/crates/nsh-platform-windows/src/native/video.rs": "74838037baaf60bc06bb299e6a1efd8117562d2d153a1c48288b331b1be3fd8e", - "third-party/nsh/crates/nsh-platform-windows/src/native/web.rs": "46a5947c569e29507c06d9f756ee2ff7a9e5f194efd9461b7db03f9c903fb442", + "third-party/nsh/crates/nsh-platform-windows/src/native/web.rs": "4b659d278ad6c1dae96b1fa16c0303768a3d2f4056bcb3f54b6c521533de6f5b", "third-party/nsh/crates/nsh-platform-windows/src/navigation.rs": "64c1846abab75dd01aeb9c8f3aa5f3299e86fba4fc99042ce04f12264a1351df", "third-party/nsh/crates/nsh-platform-windows/src/picker.rs": "bac2a270bde8bb7a5d51ecbb00f948d4fb10f8d58a1b97995d435807a67188d0", "third-party/nsh/crates/nsh-platform-windows/src/render.rs": "08f8e75f73967593de8cb8369204ab479faea87c1a70270e8984567cb1e3eaef", "third-party/nsh/crates/nsh-platform-windows/src/scroll.rs": "8f9ebb576be8ca67a288110fb94be48d3096f368a14ed0ae24e541880a59df78", - "third-party/nsh/crates/nsh-platform-windows/src/selectable_view.rs": "7e75dca099e4afc0e6ccecb5ff75051ae77b87bf758900e214739722ff4a41c9", + "third-party/nsh/crates/nsh-platform-windows/src/selectable_view.rs": "f503257d1b6b432042b8aa3c780809f7b396055c34c6e8bcf8b1143762e8a11f", "third-party/nsh/crates/nsh-platform-windows/src/shader.rs": "4fac899d5379f44924d27982b6ac40a076e0cedd2181334f4e9d56955462196e", - "third-party/nsh/crates/nsh-platform-windows/src/surface.rs": "539ddf0896d06a30e58aafbc9edd3a21a34b94762eac8f060a49f266dcbdcffe", + "third-party/nsh/crates/nsh-platform-windows/src/surface.rs": "a4479f1bb3e23eef93d2db4d61ece18c0bb4f6a58bb28e9390f6232f91fcba7c", "third-party/nsh/crates/nsh-platform-windows/src/synth.rs": "62929a5c3a50fc20cf400c8fb990be510b2828e71e572e9eb291f1e15c1f78fb", - "third-party/nsh/crates/nsh-platform-windows/src/system.rs": "367566dafc75c3127414b7e28914d96c6a64cff4173dc2bb585eb882b54c737a", - "third-party/nsh/crates/nsh-platform-windows/src/text.rs": "4d62ff6372c37c3d8fb28c13a14f676528307ab8339575f6961b7c98df4d5be0", + "third-party/nsh/crates/nsh-platform-windows/src/system.rs": "a0a13b7f47e3dec5207a5bf650b16f7dc5dcfa11ed530dab7dc35d7cbfd7d97d", + "third-party/nsh/crates/nsh-platform-windows/src/text.rs": "f30e2ce5eed5990ec9d9a001b308f83ff19207758e843dd3d83d198f6f18a806", "third-party/nsh/crates/nsh-platform-windows/src/text_input.rs": "aabb4427aa58e92bd5e359d45f907466b8c3761f3e5bc8452d53e5ea18b1ef68", - "third-party/nsh/crates/nsh-platform-windows/src/transition.rs": "780c1e42a5482a3a965ba66cdbedabab915ed9b3449f57aceecb847afa383f18", + "third-party/nsh/crates/nsh-platform-windows/src/transition.rs": "589f8784284909694788508c9a3bc3dd5984cd513b43280b526d54a96405363d", "third-party/nsh/crates/nsh-platform-windows/src/view.rs": "8e6ea0ac7a8033f3775797643528941ca4fd56a30f98da9e1bf1ce1398908e55", "third-party/nsh/crates/nsh-platform-windows/src/window.rs": "0f842c6df55ba4a1bc90d548ef4971245651edca9e12faff4113c10f4129b4a2", "third-party/nsh/crates/nsh-testing/Cargo.toml": "039818cbaa4cb8b1e2405d16f1173e4da949e063f51b1e366540d84dac0fcafa", - "third-party/nsh/crates/nsh-testing/src/agent.rs": "04382806f7a2a5de736c238c174a75438ecf5b6ed07e28f273d19593ba5701fa", + "third-party/nsh/crates/nsh-testing/src/agent.rs": "54106603f02250a0f00492830e98604e6af49101a60b72bdb817d2417e05f064", "third-party/nsh/crates/nsh-testing/src/agent/forward.rs": "6e62c09570ba63b910caa280eba059fd2ef87f056bca59d2e5413b4c313ad4b2", - "third-party/nsh/crates/nsh-testing/src/driver.rs": "d4050077ebd60fa3983732e81948c7d66a5c800fec4ec235c4ed6c8d37e17261", - "third-party/nsh/crates/nsh-testing/src/headless/animation.rs": "9f64eab6ffb8e00ec241e4daffcf38e424978b920d4a98541308db1f92f12c83", + "third-party/nsh/crates/nsh-testing/src/driver.rs": "3dbb1726fc67d0b336367d58d585cff689cc852e07ad77805dbe7c841a6fbb03", + "third-party/nsh/crates/nsh-testing/src/failure.rs": "988f26f671f907bd609901a118ae37aa25334ef365989b47f8c474fb4346cedb", + "third-party/nsh/crates/nsh-testing/src/headless/animation.rs": "2c54b8e68cd6772d13eb2f85a3d66191cd1c132c2133c200ecfc4b40c178ae28", "third-party/nsh/crates/nsh-testing/src/headless/keyboard.rs": "68fa4d51b6e16d0caa56539f7e8a790b9e6302a2bca7cba10167af9b257c5467", "third-party/nsh/crates/nsh-testing/src/headless/live.rs": "97e9ebda01076dbbc525618303316f379397af8f07a4e0fcc3372a89096f6597", "third-party/nsh/crates/nsh-testing/src/headless/menus.rs": "e5e939e682aa8ec6c663e9194e68d225eb4ac3a87411bffcc190364c750a7f7b", - "third-party/nsh/crates/nsh-testing/src/headless/mod.rs": "1683b2be9c2859ffbff6a4def078728b670eaa69a14b8cbab8a966d4a2c253d3", + "third-party/nsh/crates/nsh-testing/src/headless/mod.rs": "f7effab4ac5cf00f2c5249223c6f1deb8f699cf12729d2d7bca767d751462492", "third-party/nsh/crates/nsh-testing/src/headless/net.rs": "05b51b76f89a71f11b085001950812cd581bc9f13f9111a867b6cb3dc608f3c4", "third-party/nsh/crates/nsh-testing/src/headless/net_fetch_tests.rs": "798c72ea94333a2ce720d313a95b7e5612cf73c1830c9c8aed4ea5550e52f6e9", "third-party/nsh/crates/nsh-testing/src/headless/pointer.rs": "556722e268cec2e8d5a520565d439a518383b285f931278ed160ef40600220cc", "third-party/nsh/crates/nsh-testing/src/headless/stub.rs": "4f6a52cea583a94614c5552ec02b188d40878e69e65cd5b04ff8904a9f73ff3b", - "third-party/nsh/crates/nsh-testing/src/lib.rs": "5103cb967ac99955fa1d6e52661e82d165fb13ba64738030d14a723c632f9c86", - "third-party/nsh/crates/nsh-testing/src/menu.rs": "4bc761c4ef832b71e25630b3232d0b0a5367394b6632b2745edabb807248486e", - "third-party/nsh/crates/nsh-testing/src/module.rs": "f57d99dda8ecf977bbc756f274f1756204607d96b5f9b8d0adfde5903008cef2", + "third-party/nsh/crates/nsh-testing/src/lib.rs": "da1a970446ad7830f85bfbe9c30717c89ecf480de96358373052657d07557b61", + "third-party/nsh/crates/nsh-testing/src/menu.rs": "9fed83c6244d1350e0b5772232a27464a8a127acbef148e5cb4eec5dba6f3c38", + "third-party/nsh/crates/nsh-testing/src/module.rs": "ac3f6dfc7c55811038c2cb2849e9fdbb02d158336e4a4318fd4c15c7d1dd39d0", "third-party/nsh/crates/nsh-testing/src/page_host.rs": "6c8c8d516a491dc41568cde09fda8bc7f5974bcbce3350b4d907260eaf3eb2e8", - "third-party/nsh/crates/nsh-testing/src/run.rs": "ca918a65712c27b58a2bb2b5b12bf871a1863e046ebb5007ec09f79917b2a640", - "third-party/nsh/crates/nsh-testing/src/runner.rs": "0aa2ff2670af69de6ee9a2e16c61d161ab3b9e6e3b8cd6e770f49d78d26cd262", - "third-party/nsh/crates/nsh-testing/src/screen.rs": "f09ac2a5bbd95a0ebb345a7255e409086c5604130802e6805e3f5c13b7badca9", - "third-party/nsh/crates/nsh-testing/src/selector.rs": "5abac6cdfc0f7caafc2cdba80b8fb40cf1d32dcecbf734fbe5ae149a0f8855d9", + "third-party/nsh/crates/nsh-testing/src/run.rs": "843f10d84b0f82a4c2a2dea780cfd52c7042014de4baac2d8e1239070441a642", + "third-party/nsh/crates/nsh-testing/src/runner.rs": "e9d41ee4cbe8c544243a7357d943ba48e7c596b71c18d6fc44f05812504b689f", + "third-party/nsh/crates/nsh-testing/src/screen.rs": "a54603e341cfcfdcb068857d59ad5438135b77f242241461ce59c4724bdce5fd", + "third-party/nsh/crates/nsh-testing/src/selector.rs": "de9ab22cea92833d413f25515a46e19d9df0beb03dfa96d6a74831e589ea0e7e", "third-party/nsh/crates/nsh-testing/src/storage.rs": "72eb5c29dec32f9a8d9afaa3b8d4053a8fc8cc76b0f2bf31f1f76cfd5ad4020e", "third-party/nsh/crates/nsh-testing/src/trace.rs": "4994ee905345f43bc15d5004300024974b6bb6dd35220b62b17be4cc2c2b9c10", - "third-party/nsh/crates/nsh-testing/tests/testing/capabilities/App.test.tsx": "d8c38468e24d9125e53fd8f21503410eae0d829f0cb15148aa1743166447e294", - "third-party/nsh/crates/nsh-testing/tests/testing/capabilities/App.tsx": "36c99c0c5e804e2acf1fd6809113f35288971b657867a6d2fec0064382920947", + "third-party/nsh/crates/nsh-testing/src/values.rs": "d7913681a7d075298b2981816984c58744248b1f00b09407aa4101a7adfc1760", + "third-party/nsh/crates/nsh-testing/tests/testing/capabilities/App.test.tsx": "7715b68e9aeabc692744e55a9b0d7720cf5c30fcdba6a87faf3b1aa7b60c2e2a", + "third-party/nsh/crates/nsh-testing/tests/testing/capabilities/App.tsx": "aa7ae5e7ee6f2da9cd9c0d47a1b127397b890d01ad00d29a147eac5c1bb9bbbf", "third-party/nsh/crates/nsh-testing/tests/testing/capabilities/nsh.json": "c5b20dd63cd92f8352d57f2986f11284d268ce5dec79785e71c220bd41ead689", - "third-party/nsh/crates/nsh-testing/tests/testing/clock/App.test.tsx": "1efce46434813ee533ca4c4fa7e95ac23cf106b7f4c0c247efce439876e1f6be", + "third-party/nsh/crates/nsh-testing/tests/testing/clock/App.test.tsx": "29b9fdd7b560c23b647b91878baf5e2c651e0ea0ed75355dc9ec33891c78bf6b", "third-party/nsh/crates/nsh-testing/tests/testing/clock/App.tsx": "8f189f7affeaf27c18019a9b6b038417d8a6a098c5f9495e56d72f68dc69d152", "third-party/nsh/crates/nsh-testing/tests/testing/embedding/App.test.tsx": "de1c89b46f11f4e58dce21120bb774b853f75eead6157b8ef68d0e4036086100", "third-party/nsh/crates/nsh-testing/tests/testing/embedding/App.tsx": "45164e03868dfe188fe6a20bbb729646c9039134b83982aa3680c617c3b3cc34", "third-party/nsh/crates/nsh-testing/tests/testing/embedding/nsh.json": "58e41670d3b1fe30dc614881c14ee5246f9c18cf6aa1109ce93c801b34bd6c67", "third-party/nsh/crates/nsh-testing/tests/testing/environment/App.test.tsx": "187831fa2adc650723de6e17ed53e1bd88f80169e91daa946c2b0277c3714077", "third-party/nsh/crates/nsh-testing/tests/testing/environment/App.tsx": "9084925526aa2514a42554102e4a2a2ca3d4402ab35d5dc8508d6ed92699de8a", - "third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx": "ca8ce5622f1de5598265aa53a3b1cbe059af68fdb6a7a50f044d7266c75f8c5e", + "third-party/nsh/crates/nsh-testing/tests/testing/expect/Expect.test.ts": "3af8e8f42bf831c5bb2e437a51e66983e493935014dd040d2b4e792994cd6fa1", + "third-party/nsh/crates/nsh-testing/tests/testing/host/App.tsx": "ecb65bc2d1ad03195b84a4164d6c8f02e3546b5af20986867dc15c9ef7b723a3", "third-party/nsh/crates/nsh-testing/tests/testing/host/Broken.test.tsx": "38fc46c28004adf81e13f73e8e47322ba73c0b8b20fae40b4e221210a3037c7b", - "third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx": "af3b65cc75680b47ea1c1cb73f53a34842c0bbb4ac693042652a5aa8f5206cb7", - "third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx": "57da9b0c7f7dede954ad2def033eb98936f3133ad436d7ae49b695b841793990", + "third-party/nsh/crates/nsh-testing/tests/testing/host/Host.test.tsx": "699964dc1ad0e631b580d042999a0449fe4c045c6f6004f661b5d8b699ae0260", + "third-party/nsh/crates/nsh-testing/tests/testing/host/NoHost.test.tsx": "d85d260a3542be19139bb348e692c069f30f863c83d8a0a534180eb119a4137a", "third-party/nsh/crates/nsh-testing/tests/testing/host/host.js": "8d127bcc8a8bc959cb23c48a53fedc83f106dbfd0fd575b8812a1095bffeb1b7", "third-party/nsh/crates/nsh-testing/tests/testing/host/none.js": "56c2d2a72379cc68ea04dbf5acb31d79a2c7673d3c272b2fb6a4ad6965a74ad6", "third-party/nsh/crates/nsh-testing/tests/testing/host/nsh.json": "76e6426bcd425eb7673cb1e68d59e91acbb2f055e8c13ffaf9b05e21d727589c", - "third-party/nsh/crates/nsh-testing/tests/testing/main.rs": "66bef5d8f4c6f60e3eb90cd465efbd3a76a744c93c965e0f9f50174df3d3ad68", - "third-party/nsh/crates/nsh-testing/tests/testing/platform/App.test.tsx": "1bba8833477123de75dbc45c273626db1ddda49f040c5d5830412a4f54fd7210", + "third-party/nsh/crates/nsh-testing/tests/testing/loops/App.test.tsx": "58c831dd56eda6ff6709c221edc23076a893d7719e82ebf6f0db62b4ec303e2a", + "third-party/nsh/crates/nsh-testing/tests/testing/loops/App.tsx": "aea85730da2377e9931a7ae1af831b437dd504212cdfcea30cc053b9c905de68", + "third-party/nsh/crates/nsh-testing/tests/testing/main.rs": "a9f4960b3c71235a914b084bb3f0506d8091d00e80e9d30c67d71bf3d80439d8", + "third-party/nsh/crates/nsh-testing/tests/testing/network/App.test.tsx": "cd767086351aeeef48c5b4df6a736cda3f4d572bdb1378bd4aff57e7f8e7f2bd", + "third-party/nsh/crates/nsh-testing/tests/testing/network/App.tsx": "0b522eabd17da2bc032d2d4b7fb84efe4f010471a8dca155bb8348f8e4ab0a92", + "third-party/nsh/crates/nsh-testing/tests/testing/network/nsh.json": "da6032d323e399cd75e15ff4d3cfc989efcfdb9c5a24b42a0177ad521ba08260", + "third-party/nsh/crates/nsh-testing/tests/testing/platform/App.test.tsx": "9a4dc3e2e34ee427ed43eae0d841fb363499e5e5160039c3a8997999d7151475", "third-party/nsh/crates/nsh-testing/tests/testing/platform/App.tsx": "fd51f95f9b86534b8ec248aae81263c7e38bb2c7704012ae9f2d28a725c41613", - "third-party/nsh/crates/nsh-testing/tests/testing/queries/App.test.tsx": "cfc603ff619f179002576cd89994cd09c3f07cec4782ebf33538aef55c687449", + "third-party/nsh/crates/nsh-testing/tests/testing/queries/App.test.tsx": "1b17d7310bf3dbd2dcb9e86426e1def5d918b09506f187fb51521f204b188339", "third-party/nsh/crates/nsh-testing/tests/testing/queries/App.tsx": "595c33913f93a006e1510f5d9911357206063daf9379baf7cb1f2ab7410c3af7", "third-party/nsh/crates/nsh-testing/tests/testing/queries/Spans.test.tsx": "3f61162d75b1a95dc90922e3ad1946cc7a26b1bf8e12d535b73dfe28ddd82e9b", "third-party/nsh/crates/nsh-testing/tests/testing/queries/Spans.tsx": "2c92768cee6d649203fcdccae6b51023150529324c0a3a098903f11b2ab0d6bf", @@ -1659,55 +1722,57 @@ "third-party/nsh/crates/xtask/Cargo.toml": "f7784eb8a1ed00518a254ca8cd81dacd34c0d390c073651b28585d76afa60eef", "third-party/nsh/crates/xtask/src/android.rs": "109b621c9a9998faced113ce26c8d0d70befefe8e178d32c79b8c6673d864f30", "third-party/nsh/crates/xtask/src/compat.rs": "5511e1d357f6e94978fb455f4f9874c2b62fccbfa602bb48d882e6f152d958b0", - "third-party/nsh/crates/xtask/src/dist.rs": "9a4ee1ef69221663ebe67f3febae038cf95b6d8b1dd89af1047fb704ca9c75fb", + "third-party/nsh/crates/xtask/src/dist.rs": "0c66f3395ac8aa90c028fadbe021dd6720fa35bee76fbc2a5fc5094af561a63b", "third-party/nsh/crates/xtask/src/ios.rs": "5343c250feb010350f7a5374b90a94003dbe86df1d38f95a7551300be5d94cde", "third-party/nsh/crates/xtask/src/main.rs": "0c55e8bc293e998ab5506789a609258852c1b0f3ac3d160137b83ded969678b4", "third-party/nsh/crates/xtask/src/run.rs": "0862c5a701ed6bf204d26808c4851cb393f81ad409610368730a035be7b0c21b", "third-party/nsh/crates/xtask/src/term.rs": "c37f86f12a081dce01cac4ba3d48443b9c0b13700706d40959138edb97c774e9", - "third-party/nsh/crates/xtask/src/web.rs": "3fd2b986ea94eb649a08e111bf862965858f9b4e59e6b416fe2442e32425ed03", + "third-party/nsh/crates/xtask/src/web.rs": "cd24d97319e30f220a5f9e72525d2b6912f676b5571951d0aea6eb43cf78caa3", "third-party/nsh/docs/README.md": "61f94fc7c39a45b5ceb527e3063239dccfb26920952292e2c9d1d7e84c11a596", "third-party/nsh/docs/dev/README.md": "aa237501bbe4353fd19a3311abfa0d401cf2c46b5f1c659ff0facb5b9fb0ac53", - "third-party/nsh/docs/dev/animation.md": "be319d67b598b11fe936761e46d333abba8aa61373d46b87ce9bc3fbb3dbc0f1", - "third-party/nsh/docs/dev/api-naming.md": "70e65a17392351f54c8e4a9a8cabcabcd78ffe30ddcc96e46de0c6e4ce84061f", - "third-party/nsh/docs/dev/architecture.md": "ebb7342ebc258b96adbd3dd87a8721586650419b39d69f345cadf1bf623105f5", - "third-party/nsh/docs/dev/browser-js-size.md": "7e72c85abc669ef8070e62f653c42985dc105e24a0765f536d695a893afd1f25", - "third-party/nsh/docs/dev/canvas.md": "2a61e6f6aa5dcb8226d3fdf25774e21efdc2a5b6362c471bc66ffeccbb5b64a6", - "third-party/nsh/docs/dev/compatibility.md": "60e99484991c88195ff275fea4d8ea6ec511112cfc8e28937355c7edd81ff208", - "third-party/nsh/docs/dev/components.md": "bcad57d01f5d523ea47c0fc01f3d954a3f6f6b1efdd5029328b9c52b8c4ed20b", - "third-party/nsh/docs/dev/data-table.md": "e3ec037f7a56314de08369d18ef16b1314d3fb8586bcc58a180e8786c0e3e1ea", + "third-party/nsh/docs/dev/animation.md": "64128a73bb205e58b96bb4cdfd66370ae3d126caeb18abb4bd7604c47b6a9340", + "third-party/nsh/docs/dev/api-naming.md": "3bccea90d877b7218231c60c6ad69020b6face658f608730716ce4926f246a09", + "third-party/nsh/docs/dev/architecture.md": "8e0d42edeb92de424ab171775896bb9802b60fe8523eef21eba27bdee56506cd", + "third-party/nsh/docs/dev/browser-js-size.md": "522af42396c559aec7c2a2bd809953b2a0b00dd098110bd392cc22ce3a3ce923", + "third-party/nsh/docs/dev/canvas.md": "776d4e680faddac9284609901f3f7d3bf1ac462437735ebf9fd42f29568b7387", + "third-party/nsh/docs/dev/compatibility.md": "911d7d41a240ef454d34c1092dea90a3b081ec698bec58c4267908ca8470da3a", + "third-party/nsh/docs/dev/components.md": "1e3d2431f5282da77580188279c833c3bbb32c5208f5febabcf6a0226ceaf272", + "third-party/nsh/docs/dev/data-table.md": "054882d518360a22ef59d03d51cc0004038cddb59cafabdb49f0a29b06f95467", "third-party/nsh/docs/dev/diff.md": "749739f9a879a905fe5e1c5a6de992045af4047efce8e465945786caf403e76f", - "third-party/nsh/docs/dev/dock.md": "b65c702d0b0b60946bce995d8e4a9468331e4cdf5ae3539899d5a68b81f6627f", - "third-party/nsh/docs/dev/editor.md": "20be810853286ecb485130395e2de80ab034517e0fb7871a3492b8ddebbabab1", - "third-party/nsh/docs/dev/elements.md": "c1eae145f0ac21590369be4bc3b367e4c92bd65f3001dd25442cb2198e95714f", - "third-party/nsh/docs/dev/embedding.md": "ec1974ae55cedcb77fce6606bba42c2111aed3f50f5d3bb552738ecfd6af2d39", + "third-party/nsh/docs/dev/dock.md": "e16ce573133becd15b8beb036322d8bf7a9d4023a623044c6b545fdad490659e", + "third-party/nsh/docs/dev/editor.md": "963fdcf1b628fbb8c5b9bd9a3ab0896a70574ecde616dd1d9df8210a4509a3ce", + "third-party/nsh/docs/dev/elements.md": "1d6e6547c62af933395c885dc45f53856df7fdb36388e758f96bffecadba426a", + "third-party/nsh/docs/dev/embedding.md": "582065d0d6c2d60b73f349ceb093fa79e9670357b74551b98ae4d494ce800f3a", "third-party/nsh/docs/dev/file-drop.md": "ee9e7e263e32789d03893108e68b47d2447a0fa19b29be6b348f269884cb81b0", "third-party/nsh/docs/dev/geometry.md": "18d989e9d62b018127d622a3b0f30a202055b496aa630957fd8523863450d0a4", - "third-party/nsh/docs/dev/gpui.md": "12ad6b7e3e00e88892e60850e88d8fee06c6fa5cb2f96ec731d96490589addfb", - "third-party/nsh/docs/dev/images.md": "9e6aa2e333c9adb4f33047fc74498da42fa9494b118580cd874dde850b66bacc", - "third-party/nsh/docs/dev/js-modules.md": "fe0a104d2ea92e6a8721bba0074060edaf364203e16ef8a81e94987222db94b1", + "third-party/nsh/docs/dev/gpui.md": "ba0ff5e6247abba9d7281d3daea0815bbe54a4715aaab9031d02b2b3a5dc0833", + "third-party/nsh/docs/dev/image-loading-design.md": "c3f58a04b6e53c52e28437a79b31e13987669f1caa683a63566afb86458892dd", + "third-party/nsh/docs/dev/images.md": "b73bafa353c97bcbe2206684c429f215af8e1c852a344fd21534fe4c1da6289a", + "third-party/nsh/docs/dev/js-modules.md": "57504b18df9432fdc0ae2c88ba557a5fcc9a0fa16af7610cdf63ba7bcb994082", "third-party/nsh/docs/dev/layerfs.md": "df9b16b2f5f1e61f9693f63b61438a51461d05b535a0046d50ac9ba1ade12b8b", - "third-party/nsh/docs/dev/linux.md": "db3f8e6905bd8a7817d206cc1f96804216e3107f401cbe0e87535e48026023cb", - "third-party/nsh/docs/dev/macos.md": "5e95436f643474ea57d2b8eeea849c1e881b0fe8f350849c492188d5daa27677", - "third-party/nsh/docs/dev/native-pipeline.md": "07ab82f45731e9554d31373a02c453a9e0756c5e4d6804fb83edab27e3c8927e", - "third-party/nsh/docs/dev/performance.md": "56477463a8d1bfe6d0c5e17854329ebde2f7983d2344bc416c407234e0683eac", - "third-party/nsh/docs/dev/pitfalls.md": "0c352217005fda8ff58d5c9a39a74675b06777bb86398f1a94d00cf6b5b82d14", + "third-party/nsh/docs/dev/linux.md": "f9406e66244845c6d1c50234992eb3f069f1c710f8d541f60e60d726d85b646f", + "third-party/nsh/docs/dev/macos.md": "117f5b3f86a8643e9bb56af509d00ef9d2fc31045aef74bc5439f4179f2a6d47", + "third-party/nsh/docs/dev/native-pipeline.md": "f322511ef8a96a6b612a3872fa744cd00dec3f24d1f1ee53c93fc644f2388535", + "third-party/nsh/docs/dev/performance.md": "4078d42276c742df0922ec7c97802654bff6b697513d3fc1c3c6ce6ebceb5fd4", + "third-party/nsh/docs/dev/pitfalls.md": "239b4eb4d6be5543b15eff1cdda63216a8bfc26b2737e8f962d043dba602a602", "third-party/nsh/docs/dev/reactive-components.md": "1529481b44cdf21c2b9268f24d8cdae363f7da3eadcb047ab9c489acf69f4197", - "third-party/nsh/docs/dev/rich-editor.md": "bcda69ef84f01185f661c6aa2b0bb26fd21ced7d1c825a4063e2903fec73418a", - "third-party/nsh/docs/dev/rust-classes.md": "d001571a1a26c1084c5f53e83da29159ebd8bf2712eba220a70a40b3a4cdb809", + "third-party/nsh/docs/dev/rich-editor.md": "b41120b1a04a43af900e38200bd4d8b0db67156cb06cc889e6b459da9bea5119", + "third-party/nsh/docs/dev/rust-classes.md": "b6d29b0ad8b7927561139f60c7e33c89ea1c77256e79ce6ab35d525b35628afd", "third-party/nsh/docs/dev/rust-modules.md": "f793bfb12272ded93e80876fd2c72ff8dc0f1bd0337977f39b750af495a83592", - "third-party/nsh/docs/dev/sandbox.md": "54af4898951a234c8053bd3cabfb0f72487807e2efe238198529fedcd3622a93", + "third-party/nsh/docs/dev/sandbox.md": "1f3291672ebaf751b2439ea57b786e4a46c0f775bfd49ba2baf2f542363964f0", "third-party/nsh/docs/dev/scrolling-and-lists.md": "8329d0681a1e6872c37dc48247b9175a26d30bfb3f38ecb99848984efe802f35", "third-party/nsh/docs/dev/shaders.md": "c1ed77253ee267ed5b4b557669b800707d2d76759136ae12bdeada82b4b036ea", "third-party/nsh/docs/dev/signed-packages.md": "6e762f506428d53d347106e3619921a5595391105128305090da8eb8a20c220e", - "third-party/nsh/docs/dev/styles.md": "50055f9a50968217e429d5c21045d14f1213905e502efa995098cd0d47e8c42e", + "third-party/nsh/docs/dev/styles.md": "4a75f39d1a61736976b20bb313f885d952f757d133c7e5a66c8d239c5f887f02", "third-party/nsh/docs/dev/text-view.md": "972c43ab7891109ce84044d15612d16a98460e7b8b556aadb9306bf5fc7e7c9a", "third-party/nsh/docs/dev/text.md": "61d2fc78fefccc76ca10046311776ea487bda986a2cd6d49ec44813e71e2c701", - "third-party/nsh/docs/dev/tooling.md": "ed9bbebc9d4074ed4858cd30d6139e5bdac8ceb749019d82bb75803d58020366", + "third-party/nsh/docs/dev/tooling.md": "25eb519cd10cdd67763fd3ca4b00bc21a68210be89d3146707ead59df289dfd7", "third-party/nsh/docs/dev/tui.md": "8202587750654ba61c54e10735cb8c62e103009f537530945161047583995db4", - "third-party/nsh/docs/dev/web.md": "5af4ed3861b986de30df95480f89794bac70ef958998bed94163c1996a117c91", - "third-party/nsh/docs/dev/windows.md": "f78719cba262a084f21ffc400bc9ab803e6b495494283863e4eaf8478f61fa5b", + "third-party/nsh/docs/dev/web.md": "d161baff00c4a2ee4262a315c9ff1157b4bbb305a41f61d6a9dabb52f242d15c", + "third-party/nsh/docs/dev/windows.md": "f3cd9d95df44df8c45ca0d165229f55f1a3a16a6751e92732352f6e3b09bc897", "third-party/nsh/examples/bench/GridTs.tsx": "e1388190424c85d044232f9dfdc897acc69fc4464b23b57db149f9276336e3b4", - "third-party/nsh/examples/bench/README.md": "75d32dc5f73955e42cd171baaeb7df39b19cbdd25e67e661df7976a887ca3506", + "third-party/nsh/examples/bench/Inputs.tsx": "e519a50db7277c12e66724eccbc041c394a21a434584f9e7c2cfcb9fdc5d6828", + "third-party/nsh/examples/bench/README.md": "cbb77eb1efe870de3e67849603e248b6791b305b272873eb6da57b2c0a6c46cc", "third-party/nsh/examples/bench/RowsTs.tsx": "dbf29d668ed1f8bb9a776777eb6a86764f250ffecb9f21b66e86384a06d47af3", "third-party/nsh/examples/bench/TsButton.tsx": "5aa492261628533b00e399e906dfd6914c382a6cb1c857a783aa67b01827dbb0", "third-party/nsh/examples/bench/buttons.tsx": "535a4718dcece3c67359802aa2d3f09d29cd7d658c93811886a6e1f44b623c60", @@ -1720,10 +1785,10 @@ "third-party/nsh/examples/desktop/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea", "third-party/nsh/examples/embed-android/README.md": "60e059307a9cdfda4314f170f91a5e26a7f38d0b94144aa4004aaa4e66ce6720", "third-party/nsh/examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java": "63ea25f3fc34d98fff16c6b64fe09fed078824ea278d7cb2c2161d07f507ca46", - "third-party/nsh/examples/embed-gpui/Cargo.lock": "700a97ffdbe85a3319e0874e9744702da8e742c7ae1a6bad2756264a9b1b1feb", + "third-party/nsh/examples/embed-gpui/Cargo.lock": "964aa629ebe8f1a1210d65c7c264bd6bdcf435b477ac1bfa3ceec5ee19a73f9c", "third-party/nsh/examples/embed-gpui/Cargo.toml": "6523884dfbc5af5d8f7030600743d682e9f23c19ef982fac8441c28cea529c4e", - "third-party/nsh/examples/embed-gpui/README.md": "bf86ae2881ad77ff9bbd0292d64e2572927b0b197461fdb52c67a049f99c9dea", - "third-party/nsh/examples/embed-gpui/app/App.tsx": "d2184b4995dd16abec69bf4b1768983915ab59bbe529b16ecb07c9921d6149d9", + "third-party/nsh/examples/embed-gpui/README.md": "64f38172a247c1a52b0cec65cd0752b348865de221f008a0b26a2250ffb09100", + "third-party/nsh/examples/embed-gpui/app/App.tsx": "52218704c788d55b85df6c967d9f39fe4c316837fbeab3a1728420ded21cade1", "third-party/nsh/examples/embed-gpui/app/nsh.json": "0f5f72bacd3233d1585d5dfe497f0c624f96c2c02688fe691f249f4cc83adbe5", "third-party/nsh/examples/embed-gpui/app/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea", "third-party/nsh/examples/embed-gpui/app/ui.tsx": "a47da561339878f72887db738bad801819645dcc4e1db85d4366cacb064eb21b", @@ -1734,22 +1799,22 @@ "third-party/nsh/examples/embed-gpui/src/main.rs": "64e78833db57cb92ede02ae96fd69fdc09b037bad4ef01f810318e07c6e33b1d", "third-party/nsh/examples/embed-gpui/src/switch.rs": "1b359acc66f8b1cc9e75e7b18cf1b32c207c877468719cb73cc2ca67086220b7", "third-party/nsh/examples/embed-gpui/src/theme.rs": "04accbc983ac5ee7ade72871b853ae950279efb8837a9a35531a28cf1e703359", - "third-party/nsh/examples/embed-gpui/src/trading.rs": "e9adb7f3ecee55dc0ceb6cec0c2d08af711b2119b21d4c905aadf43aaa37aa66", + "third-party/nsh/examples/embed-gpui/src/trading.rs": "615eaa49ccc0403414238fe378a5c1615228d77a9fc7366e0fce1858225af2dc", "third-party/nsh/examples/embed-gpui/src/watchlist.rs": "7d46e105a6f00e31a3d225e5b1430616f0360cac5b7ac8a63add0ebdb6397ce2", "third-party/nsh/examples/embed-gpui/themes/violet.json": "7da650a48cebde43683ef6f50bbb27a5000dd79911763e46982688160a59e2ab", "third-party/nsh/examples/embed-ios/README.md": "603b923de1358cdf2cbd8ad6c06d97fe8d3666ecee967a766db464cfc9c52b59", "third-party/nsh/examples/embed-ios/main.swift": "07ac26d80336a3e365781355b26fb65f72f602231562f0f3f49696bd8a4667f0", - "third-party/nsh/examples/embedded/App.test.tsx": "2e09af5b97f0fb4dba20f36891b42708f9732052614ebbaac4508aa6e5a74404", - "third-party/nsh/examples/embedded/App.tsx": "3831b1de1da4f5a96fce7f3a092618a4d8f23358de9483aab48927d282c1c9e3", + "third-party/nsh/examples/embedded/App.test.tsx": "5765ba376744728b5af29ea4d6a3a601027207a9326b189bbd93284f7c2f00b4", + "third-party/nsh/examples/embedded/App.tsx": "f7e853e926b245ad7f8011b5fb90c71e8f41770f84797fe6d8964c60b46ae49e", "third-party/nsh/examples/embedded/nsh.json": "abf3b063eee984b9e481e95be5501c47aa5793101e6a91972ead975eae7b1ecd", "third-party/nsh/examples/embedded/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea", "third-party/nsh/examples/gallery-rs/App.test.tsx": "7aaa088af8ed0749b5dda6bb90e7c8746db0735d0706b7475ff0b38e1d8bc27e", "third-party/nsh/examples/gallery-rs/README.md": "ae1169fdb328e12bab7fc0b85832712e1d962f6c460a92573ac94465bade8147", - "third-party/nsh/examples/gallery-rs/animations.test.tsx": "d642f1e231d6a400c57c6447bea65e05057a5fedb3be880ebf60a850c98516d3", + "third-party/nsh/examples/gallery-rs/animations.test.tsx": "9d35570c82d7040e3d8442733b3a491709df915b0e6b43f4fb1d7ebab9804d70", "third-party/nsh/examples/gallery-rs/canvas.test.tsx": "99c603e9c4a1e3fe61d298801b5f9f382a209215af336b609755bcc0e93054dc", - "third-party/nsh/examples/gallery-rs/chat.test.tsx": "59f0b4b016cf8853f750a15ff7d814ce688831459dafa093cff9b266f23c79a6", - "third-party/nsh/examples/gallery-rs/components.test.tsx": "47aaadfeb244c9646ceea1f3a6b76835fe841afa436ca627176090771afa8a18", - "third-party/nsh/examples/gallery-rs/desktop.test.tsx": "68d683ac3c3fa303dda0ed191a1b5e971c572ceadd1c58489611eced2b7942b3", + "third-party/nsh/examples/gallery-rs/chat.test.tsx": "ab09312a6591ecec6066a7c584b1e34d6db7b9288d34542a3191cfe0b7655f04", + "third-party/nsh/examples/gallery-rs/components.test.tsx": "67b370521386b79b92797762a9bf7c43ecd784c029b6a1613a4c128bcc094a29", + "third-party/nsh/examples/gallery-rs/desktop.test.tsx": "92a4c891f30b148b3b9bbc50a9f13316b16d6975d30b3b2a5de57071a1e2d762", "third-party/nsh/examples/gallery-rs/form.test.tsx": "53c046961434f480dc013dc4c9386579d4f6528016fd4efc0c49cbdc4da0fbfa", "third-party/nsh/examples/gallery-rs/gestures.test.tsx": "9eda388b28ee315ccbdaf2cf1528e0181065577ac5f20817c80415c52b733825", "third-party/nsh/examples/gallery-rs/icon.png": "9a7291004ee88f60e4d830ff974db21c38263a367ccca7b804badbcbabd1398f", @@ -1771,7 +1836,7 @@ "third-party/nsh/examples/gallery-rs/native/src/demos/chat/mod.rs": "6bba8c1a64d0bf118083cffe621181e84181037cf229590b600ca2ddda2a090d", "third-party/nsh/examples/gallery-rs/native/src/demos/chat/parts.rs": "00da50568d4032276e0f88531573c2be932aac7014d95cc6d12e0b60e5c8baa1", "third-party/nsh/examples/gallery-rs/native/src/demos/chat/scripts.rs": "633b1cde09978a1ea80f115a82b25e4d0eeb0a05ae5fe19bcae88b8aaa0ef91b", - "third-party/nsh/examples/gallery-rs/native/src/demos/components.rs": "930c30adb683c8f18f83c00dd99bb036235c5126c31491cb4df99900f626204c", + "third-party/nsh/examples/gallery-rs/native/src/demos/components.rs": "8c3bcee60f111c479cc1ed09c8dbc79f66794f602295b6e8939755e07d5e8b8d", "third-party/nsh/examples/gallery-rs/native/src/demos/file_drop.rs": "05dec8c9f232b1a207dcb02e6b7e7b815e45235017c64fc736e3cd689c6bad3b", "third-party/nsh/examples/gallery-rs/native/src/demos/form.rs": "cc9aa0750335d697705a56d788f674a3fbfed5161de416746b65fc5ae90bb99d", "third-party/nsh/examples/gallery-rs/native/src/demos/gestures.rs": "11819718e0875e27e8e07e0978659726e576263cf2204af06b8495f044d76fbe", @@ -1815,49 +1880,57 @@ "third-party/nsh/examples/gallery-rs/nsh.json": "2fb24c4e6543bc2bff092de362985f73327222af560010bf252a6297789dcd53", "third-party/nsh/examples/gallery-rs/scrolling.test.tsx": "e031f61c45c5cb8ae72589dd098b82717151bc399833798cdeb0f124f912a19b", "third-party/nsh/examples/gallery-rs/shortcuts.test.tsx": "c9fcff2ac990622f987343515ce447a3de42277eb01a1dea839e0f09e1acddff", - "third-party/nsh/examples/gallery-rs/stocks.test.tsx": "b4883203b73dbe1fcd7969380b74366e78fb686bcf7eaa245d667c22551173c4", + "third-party/nsh/examples/gallery-rs/stocks.test.tsx": "9b8544ef53c42829c109a8309b63340ded7b49cd5608dba4e2a7783b9660d589", "third-party/nsh/examples/gallery-rs/storage.test.tsx": "2742a6248f6eddeb8edd7e3c54d0392296aa4769513f359f0720d7510f958234", - "third-party/nsh/examples/gallery-rs/styles.test.tsx": "1d0e4dc648565509ab1d72ee00c6530c674ce34f3c6e4147a7e3c252d11def86", - "third-party/nsh/examples/gallery-rs/system.test.tsx": "7631fb02f1dff502323cf0574d61683e48c2a109500b1a909cfe8d962235bca9", + "third-party/nsh/examples/gallery-rs/styles.test.tsx": "0170c5b22b88c1d31e590b98b27e0722d07ade7eff5d1f5a27c7a8bf26aab53b", + "third-party/nsh/examples/gallery-rs/system.test.tsx": "739bf393f19aef76f261e5bae1f247e42decca7866eb6ce0fb18a92e92ae72fc", "third-party/nsh/examples/gallery-rs/text.test.tsx": "65b3649a460488742786c96231694c67f5e02eff8dc1f798a32e5d539ab0295e", "third-party/nsh/examples/gallery-rs/theme.json": "7c54621c9c3b0141f1462c689dbeb9e4523463b9c40350735bfcd37151b7a28d", - "third-party/nsh/examples/gallery/App.tsx": "bf4ac1c40b6f8099da5ad247cc6357518bc2a171b9306ba23f027e0d84ef3721", + "third-party/nsh/examples/gallery/App.tsx": "c736edc9749888ae47fd0df44ade2c9e570a9b92f7a04ad26f886d5e8170dece", "third-party/nsh/examples/gallery/Bench.tsx": "be900be7d344e643d2af34a072411034d51c88e458b9b219553d88f3949b0ce0", "third-party/nsh/examples/gallery/README.md": "3405f56b12e92872b39c2535f8824c042f9e0fb3432e4e922d630e5c5e589165", "third-party/nsh/examples/gallery/TitleBar.tsx": "60a9f9e01ad0b882c668c05d7f3c26be3cd2e1e405c7542406961f8b81cb7aac", "third-party/nsh/examples/gallery/bounce.gif": "205cd6ffaf6d4575d52ae4eadd01c1a6c689ecb60b3a350f1902a8d63ca3b1ad", + "third-party/nsh/examples/gallery/demos/AccordionDemo.tsx": "b70833f75945255b8fec821b6cf8bd54c16a4c4ee05cc2e8eaf4bfffbe63e599", "third-party/nsh/examples/gallery/demos/Animations.tsx": "4905c751d0ef24aaacebec064ccf632362ee18ecaa0eca4fe072cab8daae831a", - "third-party/nsh/examples/gallery/demos/ButtonDemo.tsx": "7f0ea700620f212de8d67ff0541b7e3b79b17984f121309f8428c9bdc79d4e72", + "third-party/nsh/examples/gallery/demos/AvatarDemo.tsx": "48e4319407bb33ae251abef414a3b42c1c3d72b5347574c12f0fa7675904508f", + "third-party/nsh/examples/gallery/demos/ButtonDemo.tsx": "b0ba8743e75d320c9e8bc2a9dbb909f1a6b0fce75c228d821059c8cff8414337", "third-party/nsh/examples/gallery/demos/CanvasDemo.tsx": "0227d3bdd9efaeb03a735b3a48ea093a3794b0b0ed912d06f933b72ad2057dcd", - "third-party/nsh/examples/gallery/demos/Charts.tsx": "2713681202da7c970c7be10bb2102d365daa330e4f1e78874ad72200ef6383d8", - "third-party/nsh/examples/gallery/demos/Chat.tsx": "24e8d6e081969a8cec89a8d3073b416b69bf3bbfca85faae7a3156862c6c0422", - "third-party/nsh/examples/gallery/demos/CollapsingHeader.tsx": "ddb6ab1a7e4160a1a98583eaec83d99d0f786015d0aacfb07e93ba556e4ba324", - "third-party/nsh/examples/gallery/demos/Components.tsx": "cf5ea6554de791309b7c0e13b6c30037815f6c0dc1377d19941b9e2aad6b9673", - "third-party/nsh/examples/gallery/demos/Controls.tsx": "eaf73a7ee988cb4e8e8e75592caf932fa5dc567f5b1dd483165ae64d27601f48", + "third-party/nsh/examples/gallery/demos/Charts.tsx": "cd356b9466e67e17b67090e7cfecfd6a2c511f3daa52701584bfa42d7dae8d28", + "third-party/nsh/examples/gallery/demos/Chat.tsx": "bb11cc22d6b721a703d39710a0fe0da5906a54ef2adfff7bf4c2d9515a97af86", + "third-party/nsh/examples/gallery/demos/CollapsingHeader.tsx": "af04161972aeea65f772f8442da6dadd6efb7c493b48b319a7ccc504c2e2ac4b", + "third-party/nsh/examples/gallery/demos/ColorPickerDemo.tsx": "0dfccd961c04f14e8f804ef6fa73f1cbab651d8895e2d01e4becbb13002b48f5", + "third-party/nsh/examples/gallery/demos/Components.tsx": "2abef2c9a51dcfe44e6c5622df5388ac73ae22e84983cfed325258fe9fe85346", + "third-party/nsh/examples/gallery/demos/Controls.tsx": "ea93c59d1f7a69ee4c65f7f307efdc3b866b4219f6a8e5ad4d9b5d55b646bf2e", "third-party/nsh/examples/gallery/demos/DataTableDemo.tsx": "d9d4a21fcbd4d6798e0a30a9e2090d483977a7071cfa707a7e504027aeccae5f", "third-party/nsh/examples/gallery/demos/DiffDemo.tsx": "d241231ffd1284dd56cf9ddb16583829f25b952bab998d4c5b06caf1fd3a33c1", - "third-party/nsh/examples/gallery/demos/Dock.tsx": "0e3f897f8779dbf8bc2ae3385efac73c61423b298c95f5e7e852c571b917cb35", - "third-party/nsh/examples/gallery/demos/EditorDemo.tsx": "33b5e524cd362620194206bc3440076aa2d4783c5c20e0f44cfba7facc83f37a", - "third-party/nsh/examples/gallery/demos/Form.tsx": "1d42543aa6d70bd71688c3c1a292143de48fa9b1d56079283c0871220f3e05b5", - "third-party/nsh/examples/gallery/demos/Gestures.tsx": "76d08e24fe79693aba7702f69f47164414d82190c85cc26442c375589e26344d", + "third-party/nsh/examples/gallery/demos/Dock.tsx": "541caab3f0d06a78ac683adcabf50407d3698d8989254ba24a8bb0386dbd4ff4", + "third-party/nsh/examples/gallery/demos/EditorDemo.tsx": "ab3d7fb17bddb803ccc26c6464fe8df1f1042c2708d94ba79a638cf4723c456f", + "third-party/nsh/examples/gallery/demos/Form.tsx": "905b2a909cb7fa36dbe79ac3caf8f81c7f02df9928570da231340d3b83745e9b", + "third-party/nsh/examples/gallery/demos/Gestures.tsx": "a8f2187ab3f19d72b4ebdfa8faab9995b2f99013dc88e7c0b95eca3e4634da0f", "third-party/nsh/examples/gallery/demos/ImageViewer.tsx": "7bf7be2df73fbb23e591a41490083ea1cc6e69bc108a86f418feb0373d8389d1", "third-party/nsh/examples/gallery/demos/Images.tsx": "424751a3801ec4cbeb5d15b0ad67a8822f87f488162e3f79829c2d1b313d0e67", - "third-party/nsh/examples/gallery/demos/Lists.tsx": "8867758a8a2f7fc901a506244a35c6fb40ff7de8bfae91058d087b62b5ce332e", + "third-party/nsh/examples/gallery/demos/LinkDemo.tsx": "9ffde88d00df22966cb703551593e3580e04c790b7f27b94824d663e5a85deb7", + "third-party/nsh/examples/gallery/demos/Lists.tsx": "97f129391ed3c1e52ff92e52c2bf15e27d32cbe387b7e8b49d7808437b5afb2d", "third-party/nsh/examples/gallery/demos/Media.tsx": "c6bfd1618b35a2fa1455f8960aae4f652c5fc4f026b20252e1d6e5dadba9473b", - "third-party/nsh/examples/gallery/demos/Menus.tsx": "341f4796e40a725f0e6b0e58649f6e0d87d466968e0e9aaade75fc076395c314", - "third-party/nsh/examples/gallery/demos/Navigation.tsx": "7548210e99e8d3577c034170ce84a28b4d46e7ce1e7b9a53b90b27d5f81efea4", - "third-party/nsh/examples/gallery/demos/Overlays.tsx": "4d93ffe68fad51d0c0ae198f79fbd446684ebeb71f2f7890490d98989a9c284a", - "third-party/nsh/examples/gallery/demos/Photos.tsx": "5898441d9d95d66140f12eb64b10f53c2acb0f6559aa4efb6245d845e3a7d5ba", + "third-party/nsh/examples/gallery/demos/Menus.tsx": "6f2675f850e1f851d91f5ab1c5b9917797d7a0c16c23eaff05fdbd9ad2542588", + "third-party/nsh/examples/gallery/demos/Navigation.tsx": "33ab9b86a27a880aa605c41ce5a14c0281b3836ce49f3da93edb6e4fb41ed28b", + "third-party/nsh/examples/gallery/demos/OverlayUi.tsx": "4bc021600229609184b73525e96a0c85e4b20af7f5f91d0c3d777e83b765a45e", + "third-party/nsh/examples/gallery/demos/Overlays.tsx": "ab0a17a73ebdb0320ad4b09c7a402c4de374a60381a54448a3912db426c289e2", + "third-party/nsh/examples/gallery/demos/PaginationDemo.tsx": "fa17c2907842393ebe03b9cb8e9d7fb47a8e6a3f387b957f10c47ef3f46119cc", + "third-party/nsh/examples/gallery/demos/Photos.tsx": "38d0bc2c9a477cad2539d4e3febcc1f54ffe2dffc1d0c6d06c3206a0c9995965", "third-party/nsh/examples/gallery/demos/Physics.tsx": "5ecb44619a2d5e58cf2dbe7935d852adb7a450bb78ccb36b8e9346d1736d0b01", + "third-party/nsh/examples/gallery/demos/Popovers.tsx": "dd7ed416a4e20b8d49309f7adbcdb1213a25c50664cfbc2eb81df41351b96a1a", "third-party/nsh/examples/gallery/demos/Resizable.tsx": "f0170a8459ed0bd3e70563c9b2d58758efd0aaa1ca276cc48b4444daed6a53ec", - "third-party/nsh/examples/gallery/demos/RichEditorDemo.tsx": "35e82a6014159bfd0bafd96124b68dbb4febd30223e49efeeb41e5ba112395ea", + "third-party/nsh/examples/gallery/demos/RichEditorDemo.tsx": "d93e1af37ece99da30e441ade6e8622a17ebc62d948b909eb134e0a23f102b18", "third-party/nsh/examples/gallery/demos/RichEditorLink.tsx": "e87d5ebd2ad47941a87fd81086de4175705f7a7174fd4261c2f896d4069952bf", - "third-party/nsh/examples/gallery/demos/RichEditorUi.tsx": "e319a2b9a5d8b38b3362761d79c260cdd9843c03ba82e0aef01c27fa9f24678b", - "third-party/nsh/examples/gallery/demos/Scrolling.tsx": "572c4ada17179a3018ebb6c07483c8174545be9b59935f871ab8c9a79a8cd1b0", - "third-party/nsh/examples/gallery/demos/Shaders.tsx": "e944e372ba10a5d7c38b52daa842c3e6b161af06526912368507e838be844b2e", + "third-party/nsh/examples/gallery/demos/RichEditorUi.tsx": "212952943e188bec707ecca3813962b695edd04690e6d688cf0da568a34c25d7", + "third-party/nsh/examples/gallery/demos/Scrolling.tsx": "69d70af236e8c16ba8abe1ebd18bdeeb042bdb9c6f4ae4c9caf047efef1f3097", + "third-party/nsh/examples/gallery/demos/Shaders.tsx": "3d55a7499a0c37c4528dcd22e5cad6628cfa2abf08b2b31050b5f04b3aa007d7", + "third-party/nsh/examples/gallery/demos/Sheets.tsx": "dcc03c69645d28b226f91de4be28a693321993aa76e28df58c3d1977586e3b46", "third-party/nsh/examples/gallery/demos/Standard.tsx": "5bcef6ddae59b320541950e9c581d97bfd503f094f1fcce5a1fd8d55f80f033e", - "third-party/nsh/examples/gallery/demos/StandardActions.tsx": "c08ee2036ae8b675756c17ebe6273ab87b18217a45138c9515bf7869dc9540d6", - "third-party/nsh/examples/gallery/demos/StandardWebSockets.tsx": "561d8f1203b4b5d2dfa43050342b3219114da1b85aaa392a9922bceb9aa2a6c4", + "third-party/nsh/examples/gallery/demos/StandardActions.tsx": "e62b332e41fe874685075eaca2b5dfc78ca260a66fe59f0f2bb674397c1560e3", + "third-party/nsh/examples/gallery/demos/StandardWebSockets.tsx": "2810ed9a4ec19f35dd902375a3f6ea92249eae116f801993e19212771aa3033d", "third-party/nsh/examples/gallery/demos/Stocks.tsx": "5be226a20be2959175ae5a9355f4dabf0e7b2920d2bbd94dab352e25607b87ce", "third-party/nsh/examples/gallery/demos/Storage.tsx": "1d4f348ca677644f91f76cd438f6b22a8be5799ff635cec70da089f18caa9205", "third-party/nsh/examples/gallery/demos/Styles.tsx": "2022f28cc5069ea8b7179e81cb9e22e732efcfa2cb9af91e6f3063eefbcf807d", @@ -1865,6 +1938,10 @@ "third-party/nsh/examples/gallery/demos/Tailwind.tsx": "6b705a51a51eadd9ce4c89ca024896f7d883710cb3da57bf8b83f9010fb369f0", "third-party/nsh/examples/gallery/demos/TextDemo.tsx": "55bf77ba0a6d2851fd750787a11a6ce6d9585896f98a9455892cb7c4a4defc94", "third-party/nsh/examples/gallery/demos/TextViewDemo.tsx": "642b8021349fa59441f785c98b9315afbf11eeca509271003145d5c6d7aa1854", + "third-party/nsh/examples/gallery/demos/Toasts.tsx": "d5cf1cd5eb08dfe5368335020f8a809336883bceb043f788f85721acce5b5c2e", + "third-party/nsh/examples/gallery/demos/ToggleDemo.tsx": "a8f140712aca171ae86074bb52239396938353abcf877601234b35493e0383bf", + "third-party/nsh/examples/gallery/demos/ToolbarDemo.tsx": "99f70d164ca5c0bfa84ef28a375bdd36c7042f3988612673fcef4338c9a6cac6", + "third-party/nsh/examples/gallery/demos/Tooltips.tsx": "cae883507f23677e668b1ec3ba76ba9457d64d3c82c8cd390bb4c580aaeb509b", "third-party/nsh/examples/gallery/demos/chat/glyphs.tsx": "7780dce59e581afc2cb0dee5f04cb5012596ebe67742d80b9d90a982329cbb11", "third-party/nsh/examples/gallery/demos/chat/local.ts": "db4aa41fca179bacaa4acaec9cd5e0ad93ac56209f4aa5d7578596af6ebb6719", "third-party/nsh/examples/gallery/demos/chat/markdown.tsx": "02cf4b0ed97028b1bf8ace6975fd10ad90e657e39854e78bd7e8b1402c297693", @@ -1882,26 +1959,31 @@ "third-party/nsh/examples/gallery/fonts/OFL.txt": "a47e5daeda73568969395c656823102678f2eefb0d7d7ecb47aac4cc17e42204", "third-party/nsh/examples/gallery/fonts/Pacifico-Regular.ttf": "5b6c0d5334a7bf77dea52b975c5a0c408878c0f7115ed5b6fb151f634b7bf701", "third-party/nsh/examples/gallery/icon.png": "9a7291004ee88f60e4d830ff974db21c38263a367ccca7b804badbcbabd1398f", - "third-party/nsh/examples/gallery/icons.tsx": "2f9ee351441ec1fcdc2c0cdca6c72aee5bc31341b4ed27cd05f8b45434545afb", + "third-party/nsh/examples/gallery/icons.tsx": "d35abbd35f88479647bb690d2eda1b32087068986de998989d6943594f8b53e6", "third-party/nsh/examples/gallery/logo-dark.svg": "d7eb08459a0624c40d6332c0562e8773e5e90bbb62fa110f5b9c7a43f6705516", "third-party/nsh/examples/gallery/logo-light.svg": "4f5a84e5419c54d00da108070bd1785f1e1dd8d09d1c9fa6634fa1cb64f12396", "third-party/nsh/examples/gallery/logo@2x.png": "ac8c8d7b052f7ca39f8bba0a2a89ee8f971dde02ef9fe86f75b94535e8d3c48c", - "third-party/nsh/examples/gallery/lucide.ts": "64d8794c47ee315a7095c64ab17163ea8140b383d4e6f612fa7e798eaf624667", + "third-party/nsh/examples/gallery/lucide.ts": "04045276d078a49f3ba054718a48fd4f3289fab73845e69dcef6fb5108ba071e", "third-party/nsh/examples/gallery/mark.svg": "a2a315ee0bc3a366e7287aa27c09e7df031baba90e1e6860d08d1cc872187ff7", "third-party/nsh/examples/gallery/nav.ts": "2ea23ee3b43ffbb16ba7ab1f05a3030a2a9457b27f9eaa2aef1d11eed9eae7d6", "third-party/nsh/examples/gallery/nsh.json": "4a0964ba703104f653ecd71534126644bbe022f8283c225a423586823f6b7f12", - "third-party/nsh/examples/gallery/scripts/lucide.py": "861220b5a70eefa3a665fa87f010699c0cfab1021f58fc811aad3527940e07ec", + "third-party/nsh/examples/gallery/scripts/lucide.py": "c227fabfbbec5e0f10669b115b08fccfa8968bedbb542b6abb62faad439cdf5a", "third-party/nsh/examples/gallery/spinner.webp": "43b19bf2baa2704f8387c7b28b02f9349814d1030f5679ac9825fedc3495047b", "third-party/nsh/examples/gallery/star.svg": "1d3a1718dc70730c937a7f4aaffc857279360ba0cb46364aa94629e49ea5c6ed", + "third-party/nsh/examples/gallery/tests/accordion.test.tsx": "0502a5429274cc597399e0bb94803ccd274121f22974479657f0ddbb388d1af0", "third-party/nsh/examples/gallery/tests/animations.test.tsx": "dd968f4bb9685d86fb6b42a58ea4c7e078f98893b670a89efdeb66433e7feed1", - "third-party/nsh/examples/gallery/tests/charts.test.tsx": "df34d3c183050f1f7cb1b3cef18bc275a32815d0922fe28aa11ec772ce508fc4", - "third-party/nsh/examples/gallery/tests/combobox.test.tsx": "7f4111952a13e5b59255d7f4bff8756b6cd49921bdfa85a157a6a490ffe5e937", - "third-party/nsh/examples/gallery/tests/context-menu-press.test.tsx": "568760ff602e34f561652a33fdbabe187f4e40c04cbf3054faccd0690213df99", - "third-party/nsh/examples/gallery/tests/context-menu.test.tsx": "9e79c3bec515230f44e161c0f794a1a1a6ef80545ca864bf0f4d22389fba12f7", - "third-party/nsh/examples/gallery/tests/controls.test.tsx": "3eb1dd3e59fa037d318cd6a46e198f5d52edec993af9eaa0bc9c905444e32f96", + "third-party/nsh/examples/gallery/tests/avatar.test.tsx": "3c80d332297f8b089360fd62d62d3f30ec3b45873524d120d5262609ef968fed", + "third-party/nsh/examples/gallery/tests/button.test.tsx": "d0d0f3f4fb1ce8db2fa588585b9c2cf76ba1fa162646a23793bd0ec2d60cba9a", + "third-party/nsh/examples/gallery/tests/charts.test.tsx": "33a9e617f7d9c406461f0ca9f81b57ab3c46775507f99dbc8f1df0e544920ca1", + "third-party/nsh/examples/gallery/tests/color-picker.test.tsx": "f520fb53e89405df264d2813e95bb31545b21a478631129f6109ea7ddd3e7846", + "third-party/nsh/examples/gallery/tests/combobox.test.tsx": "d41b7bcf8a07bf9ac4a9dc7cb95fa06a835a0dfced318a456df9561c1be6da44", + "third-party/nsh/examples/gallery/tests/context-menu-press.test.tsx": "677eaf9bf34b69ab1102bd234663a50a17d8f7e7a6d075a21c35e5f5be938721", + "third-party/nsh/examples/gallery/tests/context-menu.test.tsx": "7331f6311a1af2f65b23d83eb7eabf8292b5798e8e6182bdadd05c9af3e0fe42", + "third-party/nsh/examples/gallery/tests/controls.test.tsx": "ff9f22981cb19056f1ed5083ec6085120f840ef1d28560dcea2ef1ab60a5d6b5", "third-party/nsh/examples/gallery/tests/data-table-columns.test.tsx": "7448dde80a9227c08bcd128b4bb62ca1feb427d0089528ae2eb7ccdfe8ca38f1", "third-party/nsh/examples/gallery/tests/data-table-interactions.test.tsx": "0a786bc67e6ac729f2afeb5f8cc462bbcccede71c553990a1b05e20b1453aace", "third-party/nsh/examples/gallery/tests/data-table.test.tsx": "319691a130ec1e3baba5b3b11adb637615ec131b2b3005caaf8c21fb4a6f6872", + "third-party/nsh/examples/gallery/tests/date-picker.test.tsx": "fe7c13dce51e7eaed6721ddd375d728f95e6f6082533bbfd0253602f958cd60c", "third-party/nsh/examples/gallery/tests/device.test.tsx": "8a1135797c1e91f1fdf5124cb1d96293ea2109094900175eaf53cd95feda7cc1", "third-party/nsh/examples/gallery/tests/diff.test.tsx": "e1f285f55e5468554453a3f25549b05e634a01d72a564becd133fa5aad2e0cd3", "third-party/nsh/examples/gallery/tests/dock.test.tsx": "e036272c678080734a416bb04aefe792a919f3dbaae8c072afd8bf7d651b6e72", @@ -1910,43 +1992,50 @@ "third-party/nsh/examples/gallery/tests/error-boundary.test.tsx": "f71d9c316fcbea37f15a7b3be31b7e82aacb09c6b550553f3636dccd9534e94d", "third-party/nsh/examples/gallery/tests/fixtures/context-menu.tsx": "a5978874386a1f8f39d1b4fd67ec9711a123b349a495ef77175c72dc4c48a14d", "third-party/nsh/examples/gallery/tests/fixtures/data-table-columns.tsx": "621aad1a0dac1c566adcf8bc49b93ad9eba3d1c56dffff9d0893ffa660b67c52", - "third-party/nsh/examples/gallery/tests/fixtures/data-table-interactions.tsx": "439ea706f534447cfa56e577a216a9d8ee41ca482fd1f51086325424b7944bc2", + "third-party/nsh/examples/gallery/tests/fixtures/data-table-interactions.tsx": "5e0e4a803a8015b4464b781e2f16252116622bb5f977bde0d13a49894f86889b", "third-party/nsh/examples/gallery/tests/fixtures/error-boundary.tsx": "94516c85cc83f4824d71bcfac24ba972d5dc3bf6ea39060752a5d6e097ecca7a", + "third-party/nsh/examples/gallery/tests/fixtures/keyboard-taps.tsx": "b3ecf7135e614b15af905a3af72610f316892dbb970661946fa877c034748d09", "third-party/nsh/examples/gallery/tests/fixtures/link-truncated.tsx": "82b49bbb03e4b0c8e76258d9e938b2ed5844fd4e298d746c91629308d0d919d8", "third-party/nsh/examples/gallery/tests/fixtures/list-search.tsx": "8484514f58df5b403c2822351becf9b1aee741c2aa53aeb8b19cc79021202346", "third-party/nsh/examples/gallery/tests/fixtures/mention.tsx": "be17067432c92e6f495b3fb34c847031a06326214b3628e77e0f087105e42505", "third-party/nsh/examples/gallery/tests/fixtures/navigation-hidden-header.tsx": "9951d51e847a3dd4ec6841c14666f1539b621fb371fa063a1672a0039e184f5a", "third-party/nsh/examples/gallery/tests/fixtures/press.tsx": "a91869ba8b693abaa3ca130a46bea568a4c251f20e4cfa7db6965a45a456181e", "third-party/nsh/examples/gallery/tests/fixtures/shift-key.tsx": "92d1a7f0b8bc68b9508dc2fb1986be19712ad70a118e0e83c15c545e2ff89bca", - "third-party/nsh/examples/gallery/tests/form.test.tsx": "fd70e1e92c8647134629602a7f997c16a19c5686e1e7cc5767b96c22b09ce939", + "third-party/nsh/examples/gallery/tests/form.test.tsx": "4382858d891b82f85182db74c4e6488467d2d53e7eb4bcdffbd37ef6213d599f", "third-party/nsh/examples/gallery/tests/gallery-ui.test.tsx": "11abee60a51866e5e981a2551959d9790dc40381c534e6f2bb1ca09fcff47303", "third-party/nsh/examples/gallery/tests/gestures.test.tsx": "adf7fcf2afe5029f2441eaf048251a1d33530467df91747f170968fbdc6f3a08", "third-party/nsh/examples/gallery/tests/hover.test.tsx": "dae609cc2f03243b33ef3d46f58b1dadef73645b1223f1796c3f246606baa5bb", + "third-party/nsh/examples/gallery/tests/keyboard-taps.test.tsx": "02747ffd88bb9457558d71907835b4660d127080263a00029b05beca8797c181", + "third-party/nsh/examples/gallery/tests/link.test.tsx": "aee801ae90e72071856fe5b8322772618b727bb0914e000dd6578bde44011452", "third-party/nsh/examples/gallery/tests/links.test.tsx": "d024f662707ae87840bbab4d2a0e0ea86493359aeaaa6e69633d2b56fd5dc89d", - "third-party/nsh/examples/gallery/tests/list-search.test.tsx": "80eaf7e15d7f285662f1c29c662ac075722f04a0cb44220f8a1a5c55033f1ac6", - "third-party/nsh/examples/gallery/tests/mention.test.tsx": "29de0aeb17fec43c87502c850b72d88754f0da389a50119ecd83b0503d435574", - "third-party/nsh/examples/gallery/tests/menus.test.tsx": "76df15672ad7eeaf9a7fe12e83a08f5f88e2aa041aa9b4eb6d21d5bc1b16879e", + "third-party/nsh/examples/gallery/tests/list-search.test.tsx": "c7ebf547aaf71ef1716ca2b39eb6c16ad36ac1184cb0c3d27a821e6660df7d4e", + "third-party/nsh/examples/gallery/tests/mention.test.tsx": "7ea0a871ad55b8ca96e9bc67fb2b3daa7cd104561caded5df1950cf1dfda8e70", + "third-party/nsh/examples/gallery/tests/menus.test.tsx": "21b3a0de786441309b205de0e8b1d3781b37e859f4d3407499cc64260f0f720a", "third-party/nsh/examples/gallery/tests/navigation.test.tsx": "61176cf15ee02fa3e63b555929b4b01bd84b95f6d9d9ec066625a2cadb1ebf6c", "third-party/nsh/examples/gallery/tests/network.test.tsx": "aaab1989a62f65e9c92c69ef8fe56fdba65677db382a00796b37b21199bd7d15", - "third-party/nsh/examples/gallery/tests/overlays.test.tsx": "e5a01b23a6412b79e51bfce983ef8683debd6b454dc08c57d57cbbad9d09d74d", + "third-party/nsh/examples/gallery/tests/overlays.test.tsx": "c1988f2878349a0b0b1061a9312ae0e6d8b290a63ad5d16238ae93b7c2e1f3e6", + "third-party/nsh/examples/gallery/tests/pagination.test.tsx": "52f50d8fd6edfd546ae3ecae970575196277c91773fc4cd4a5c1b1a69e2c745f", "third-party/nsh/examples/gallery/tests/photos.test.tsx": "7d8823e844d517c85439f28bccc909c242f5ae4bfaeaaa9c422025c6f81805ef", "third-party/nsh/examples/gallery/tests/picker.test.tsx": "6824bb7d3d16f573e1df9d3d1a00f9038afe17c9514907c52e9b23466ba7f774", "third-party/nsh/examples/gallery/tests/press.test.tsx": "264fdac63a1fd6df10d865b5f3eee809664dd2e77f3f05497bfe7865d7d0fa13", "third-party/nsh/examples/gallery/tests/resizable.test.tsx": "35ece487be79c234116627e82c41858371f92fc1fcca1c197a9e53a366a1d3cc", "third-party/nsh/examples/gallery/tests/rich-editor-session.test.tsx": "0b66929b5cc429bd717984a11ccf43d0d12d994ae8402547021e208a3edb6ec4", "third-party/nsh/examples/gallery/tests/rich-editor.test.tsx": "658d5c53f97d109af36dac922b290700be5196004d52ed5f02f375f9519f554f", - "third-party/nsh/examples/gallery/tests/select.test.tsx": "fb46ca3a16442bfe37ca44eee7ef395569b64d0c85e2c20b6b2902a955736636", - "third-party/nsh/examples/gallery/tests/shift-key.test.tsx": "7a91fe0694891865396d27fe30841ac64ddfc44122b5b280835e2dc37c2b4891", + "third-party/nsh/examples/gallery/tests/select.test.tsx": "f7cd6bbab53db70063ba50407fdf3cc3017dd22bf735c718c8f6933c5f3bc7e2", + "third-party/nsh/examples/gallery/tests/shift-key.test.tsx": "af9ed2647c71867483edc1dfe18be3dbb3a6b0b24240e18925ac4c64eff457e2", "third-party/nsh/examples/gallery/tests/shortcuts.test.tsx": "81eaed6496d0522bc1f00146c59bcf7fcd7839f3587314684cb5b9e41e06d8ad", - "third-party/nsh/examples/gallery/tests/storage.test.tsx": "02e333f406ff506febdc2659976e1b3422a3d86d4ccd53df076df217da72b2f9", - "third-party/nsh/examples/gallery/tests/text-view.test.tsx": "1f25038e4fd86a0260a69d7c5d5eb7ca79dbbd9276e4027d8bfebc3e533b0914", - "third-party/nsh/examples/gallery/tests/timers.test.tsx": "2e89d1097247ef6bed7ce0dc92ba6ac06ed5504cf75ed10cadd2993149f2f855", - "third-party/nsh/examples/gallery/tests/toasts.test.tsx": "4e86b2bdf2a65ee1b2d243b3ea12335de6ddafdb242c2d6670fdc2c5132c6307", - "third-party/nsh/examples/gallery/toast.tsx": "b10265591a7572af33e9356e8f59765cccc21e0fb734544a89d6b766bc29cf28", + "third-party/nsh/examples/gallery/tests/sidebar.test.tsx": "7eb68ac59df4ab9bb7d732a2f23eef26cabafdd38234922911b9a47b31c07941", + "third-party/nsh/examples/gallery/tests/storage.test.tsx": "db9a223c6077bb68a22576ecad545300d227c785db98f39048437b86ca67f289", + "third-party/nsh/examples/gallery/tests/text-view.test.tsx": "6cf204e1b55126ea930935fba5d3da1df38a41550f0e53f662a46e0fe2a1304a", + "third-party/nsh/examples/gallery/tests/timers.test.tsx": "5cd28cca25f087ef3102ac8b8286354d41d498da276abee08711101c9628006b", + "third-party/nsh/examples/gallery/tests/toasts.test.tsx": "841f864649633ebff70067536760443e577bda01039c62392ceb538523bff643", + "third-party/nsh/examples/gallery/tests/toggle.test.tsx": "0f98704f42dd13b81a51b019ff59572e475a17d53972ebdd3a8bb0a18a40aae0", + "third-party/nsh/examples/gallery/tests/toolbar.test.tsx": "8a271af155fd997c45000c2d2f6345504793e636391b9babf3d6be8d4a07128b", + "third-party/nsh/examples/gallery/toast.tsx": "5bec36a38a277e3810c5cde5dae91734fd0e72e583f9070f76023241d25d0b86", "third-party/nsh/examples/gallery/tools/echo.py": "4d39f83c3c71d9ba114d4bdf68ab34b145a728763098f6757c7a351697969953", "third-party/nsh/examples/gallery/tools/perf.py": "7a82621f82df9f3c692f57a92db8f17917a75e00ebad36d739627dc78118ce14", "third-party/nsh/examples/gallery/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea", - "third-party/nsh/examples/gallery/ui.tsx": "edde59ae014bbdf9874b629e5979dd39d91a754bca3864eaf30c223404f9913a", + "third-party/nsh/examples/gallery/ui.tsx": "78fc421758cc6b213820c25fd828eff6d16a916bbf403922b922a5ea4355d481", "third-party/nsh/examples/hopper/App.tsx": "3cd97004f667857ee29ef558010d17c99f77977e81ae1579c32b132eed733645", "third-party/nsh/examples/hopper/FINDINGS.md": "91d87633939eec8a47587b5e7d33d6c0730784f9de1437638bdb5c064747f14d", "third-party/nsh/examples/hopper/cards/FlightPlan.tsx": "36a31a46fb69f28d97e78bb54ba5dfab5ca3e68cd174d781cf1c56a7bdf6610b", @@ -1961,27 +2050,29 @@ "third-party/nsh/examples/hopper/theme.ts": "635f518b81e7408e5969ca59dbd25860381857f37e9980d225a1a54ba373a38b", "third-party/nsh/examples/hopper/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea", "third-party/nsh/examples/hopper/ui.tsx": "0e99011c52b1beb63137af7592dfa6d8bb9cad69a45b67f6041b6ef8d1998df2", - "third-party/nsh/examples/host-echo/App.test.tsx": "ccd51aa627dc36087d36614b02c97dd1f08513146261b2b282a7997d1594d8f0", + "third-party/nsh/examples/host-echo/App.test.tsx": "332d13ebbe4ae9a2fecb7ba42b1c6caf74091218608ffeec1745754f1578f25b", "third-party/nsh/examples/host-echo/App.tsx": "1b81922723f6e6b98be78561ad570e8d45694a99a5f6d29eb33b63740ebe638e", "third-party/nsh/examples/host-echo/host.js": "da4dfc0d7be297fdb12ad686bcd251d9da4036ecd2aa5b2058d685f4b7ebfa9b", "third-party/nsh/examples/host-echo/nsh.json": "c0059e3ecaeb57be9f29502f727184234d7166e1e0e1f9fb7c22a80f97000430", "third-party/nsh/examples/host-echo/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea", - "third-party/nsh/examples/nsh-gallery-ui/README.md": "8d973de86ee0e0e1a6e94e27d60b5f90215f7d54875d6d28d3d8c245111941a6", + "third-party/nsh/examples/nsh-gallery-ui/README.md": "367e5c43623e09f2fb747a47ce95e72591386170c5705f3c781d81b21bdf9f47", + "third-party/nsh/examples/nsh-gallery-ui/avatar.ts": "41b695df18b137a24734e3bb2ac4cc987091d6f2d95733a37209714e3c68f0cc", "third-party/nsh/examples/nsh-gallery-ui/css.js": "28f0671ecd5b15395d8835410440e036eff00ac64dfcb31e8b0c73977243940d", "third-party/nsh/examples/nsh-gallery-ui/data-table.ts": "c8d13a1b73f8e7c12183d5e6cbde74aee935d1b998f006301318fb1a85f954da", - "third-party/nsh/examples/nsh-gallery-ui/demo.ts": "ee80ed9f8a3b95bb7f72b335db1f9f1cd765355a338f2cf907b2754539db6a34", - "third-party/nsh/examples/nsh-gallery-ui/finishes.json": "61c0d6869ba34eee284918841303ebacc97aa4164df579054a8643bdaa7d5d8d", - "third-party/nsh/examples/nsh-gallery-ui/icons.tsx": "1799c6cc2ae2ed8947a4e2f38ed72db7f8bcc9c0600eb50d9787ac3797c3ce4d", - "third-party/nsh/examples/nsh-gallery-ui/index.tsx": "2fb1428a72c03a7a24e829da767be04fa943018a9e71858eae44a6fe077b8362", + "third-party/nsh/examples/nsh-gallery-ui/demo.ts": "fe8db0c65249b78a8f8a638be8932c0c6fca4f1f2b6d38bf3d28c4b2b498a67c", + "third-party/nsh/examples/nsh-gallery-ui/finishes.json": "ebcc719fa25aaac4e58a72ea01e940019ec4afbd683dc0edce5a3f18bb948f06", + "third-party/nsh/examples/nsh-gallery-ui/icons.tsx": "ed95aaa4ca5acd1d2a936be2d484991bfdb1e13c9cd620b7bc3d052ae887ea15", + "third-party/nsh/examples/nsh-gallery-ui/index.tsx": "8e71b44e5b34f77c00a5b914fdf5acac6f504054354721b239f86c9c57b285aa", "third-party/nsh/examples/nsh-gallery-ui/package.json": "d34672b3536a70fdf7ae4fbdb6141170a84d158278065b56696714012aa91518", "third-party/nsh/examples/nsh-gallery-ui/palette.ts": "5ccc0fb238373bcb1303c45ba657ef525dac248af71d706272686bc9206fa013", - "third-party/nsh/examples/nsh-gallery-ui/scrollbar-theme.ts": "2187609defa94c686e8066e96c847ef750073d92e1fe170bda4b5c4a17395dec", - "third-party/nsh/examples/nsh-gallery-ui/segmented.tsx": "84083c632ef72a674fb3465472ad18e785178509c6c4584fcd96a6712a0cfdac", - "third-party/nsh/examples/nsh-gallery-ui/slider.ts": "a941d0e7799ac49954660f9fba944452e5045463e7a87b352d1b0cf7a08b5325", + "third-party/nsh/examples/nsh-gallery-ui/popup.tsx": "28122d5d98b412df9fa0743830523175fbf5837fa2865c7c4440e441310e3f6b", + "third-party/nsh/examples/nsh-gallery-ui/scrollbar-theme.ts": "8ac5f6aac9b7c793e2b12ca03e6c77838859f9721f4475b8ed9fb3b821c0fdc8", + "third-party/nsh/examples/nsh-gallery-ui/segmented.tsx": "7b9ec141448294de561f2243ce89f9ad47c125c48d4c38ca9a62a3616fdfbe04", + "third-party/nsh/examples/nsh-gallery-ui/slider.ts": "05384a7b42b59b0b0b9b8bec3ff987738e12dd6d8da92024e202202bfad569fc", "third-party/nsh/examples/nsh-gallery-ui/tests/App.tsx": "2cf3f71c4264fe2f5ec79ccf794161e54c33c59014678de5b151c702d2fe218a", "third-party/nsh/examples/nsh-gallery-ui/tests/nsh.json": "3b8cfa951ae851f9095b085d00b2b78ad76e291177d0583d1ba9ff4b7c360d46", - "third-party/nsh/examples/nsh-gallery-ui/theme.json": "a5a0284f0d34eeb7b74a3d563f510860ee4b0b3aca28a83c67d5bba6edb02c81", - "third-party/nsh/examples/nsh-gallery-ui/theme.ts": "d722b3f2d22097aeb7b9f151721f7e5fa35ab1d386d0d8a2d30256fddd15cd37", + "third-party/nsh/examples/nsh-gallery-ui/theme.json": "c9a154417c8ee75c9a30ea9cc3338e4067e8c351d46b1fd22df842e5c226b89e", + "third-party/nsh/examples/nsh-gallery-ui/theme.ts": "d9e70c5bc6c4e1ea234f6b46846d4aca1bb26bf187b66a3d03741180c553210c", "third-party/nsh/examples/rust-exports/App.test.tsx": "98df2fa881eb3b6d1c383c0b6f77d6753d178e93df556111258dbdfc66db7cec", "third-party/nsh/examples/rust-exports/App.tsx": "8b7bd14d3a8108fb3a726915f0e17f916c6e7aedcd52f42a266bd5a26e0f87cc", "third-party/nsh/examples/rust-exports/README.md": "481ffe33834c3e808d745e9189cf0f28c44c6c0251e6f9dec2da89e25ae87ed4", @@ -2009,36 +2100,45 @@ "third-party/nsh/rust-toolchain.toml": "fb9781152dbb601c53ec070f53142e569c946e4aa1571ac4800d6def4d7a4f1f", "third-party/nsh/scripts/layerfs": "be28fd34d5316cb799d0f2ea042395b478ebc5c3da6dd300c63907efa56ab273", "third-party/nsh/scripts/wasm-size.py": "5c31d3c2c44be66c8e320dbdcbf3f0f9fa7bbea82a317166df078db4174b6489", - "third-party/nsh/tests/compat/cases/android/App.tsx": "4955fb9e19e6a54908266f59c8d12c30b6a29e745bd2b11479044d86f68858cf", + "third-party/nsh/tests/compat/cases/android/App.tsx": "395202daaf09738884e9140f88757c13002b055b0904d9bdc7ddc28a84edb2b2", "third-party/nsh/tests/compat/cases/android/base-dialog-android.tsx": "b4e15d8123bb9bb007fa1d4ac3acf6a722d471c6e8f3a5d426aa141d83c22499", + "third-party/nsh/tests/compat/cases/android/mod-clipboard-android.tsx": "01abb5df99932452f914f027949bcf8fc7fe404127e7c51045e8fe6eab25f938", "third-party/nsh/tests/compat/cases/android/nsh.json": "5ba36bc6e298ca19a83a93c24fcfef0645341bed68564870f74b4b8c88e7d5ed", "third-party/nsh/tests/compat/cases/android/props-platform-android.tsx": "673ad7ff955f51a56dba48d12d0a8430582129907f2cd531f1681bb806072667", "third-party/nsh/tests/compat/cases/android/props-view-android.tsx": "a14407c8d93dd26d709c420f0a58a820697943af1d0c4c9a5516b5516a45a557", - "third-party/nsh/tests/compat/cases/android/script.txt": "32fd798589800e9d1515885da38dd4052d74ccc623789cb5e1475c87faf4f147", - "third-party/nsh/tests/compat/cases/base/App.tsx": "421d2df2a2de390d58e95948ea471c3f4b2a61f02c45fb874d97ff9c0fa506b7", - "third-party/nsh/tests/compat/cases/base/base-alert-dialog.tsx": "56434f48d0e2e9db29d214455ac2c7dc276ae04e0022075d5289a54eea9bf6da", + "third-party/nsh/tests/compat/cases/android/script.txt": "2b1b934991e332f0da2b509c7ce166da5a412ae174b02eab9d5bbd2020e7d0b3", + "third-party/nsh/tests/compat/cases/base/App.tsx": "4a361cae02ca642fff1939e67fb10db53cd4c3dfad97f0413d2b11c244668cd4", + "third-party/nsh/tests/compat/cases/base/base-accordion.tsx": "6e1b4ba42ccfa80569624cd35f197790364467b1ce07cbb6f1691518e7f7352d", + "third-party/nsh/tests/compat/cases/base/base-alert-dialog.tsx": "a6a6063762b7b3fad8eaf66c5ef76ef864472c5ab7c844021c843690ef9383fe", + "third-party/nsh/tests/compat/cases/base/base-avatar.tsx": "621d96841b1045b8ce795987bc71341fbe575c7c6280e150482c67e4a7830d6d", "third-party/nsh/tests/compat/cases/base/base-button.tsx": "686514fc9f01b558d0cb673ab4fadbfda56a60c727870c7856155c0eee13817d", "third-party/nsh/tests/compat/cases/base/base-checkbox.tsx": "22968d29a59ebcbf82ecf7477bb44c215a4a94ee4773a8c20e1001fc2b8f63ad", + "third-party/nsh/tests/compat/cases/base/base-collapsible-reveal.tsx": "c1c3d83ec2eb89f197d78e64003047a7b04ae789df0b72d7c388646c83bf1dc2", "third-party/nsh/tests/compat/cases/base/base-collapsible.tsx": "47803b6410ae6797abd38392fceb626286e4977941554d83211bc0c7f1b82501", + "third-party/nsh/tests/compat/cases/base/base-color-picker.tsx": "464416491b841ff7a188dd96c9e14823ca2017705ebdad8a81139da3496e3409", "third-party/nsh/tests/compat/cases/base/base-combobox.tsx": "bf189a3d3a35938ea22c815569b8204c731080934a539ed9ffe65ce905467b5f", "third-party/nsh/tests/compat/cases/base/base-data-table.tsx": "e85829328e29d9d8d396c5e04470f63657db9d829a87988fec993a7e1246986f", + "third-party/nsh/tests/compat/cases/base/base-date-picker.tsx": "4062033e8e421b0de5509ddd954d6cc0505a92c3972196687955ef7b2d6fb8d2", "third-party/nsh/tests/compat/cases/base/base-dialog-backdrop.tsx": "ce092f6e72057f62eda135ff8633fea9a9881c0f8380167d548b3d43200e3290", "third-party/nsh/tests/compat/cases/base/base-dialog.tsx": "1f21f32ced2ea70ba40840d1205f506faeebe5f79f6dd0165ff64af2dc5eba3b", "third-party/nsh/tests/compat/cases/base/base-diff.tsx": "a95a98846285168efd9c2ff0db80548cb412f50cd9ea9d7a97160e75b8de1855", "third-party/nsh/tests/compat/cases/base/base-dock.tsx": "34945dc14d9617886b6cb4f86996a54cde94b5558660680d8cba588831f1796a", "third-party/nsh/tests/compat/cases/base/base-editor.tsx": "00cc7c71a954b7a02070405a3082e05b4cefe5870faf203383fd19e7e1d1b455", + "third-party/nsh/tests/compat/cases/base/base-link.tsx": "5934aab25e927ec80a87264eebe779eafced620aa0929b33d05f09a08e26882c", + "third-party/nsh/tests/compat/cases/base/base-menu-checked.tsx": "44828b25038cb9d33681bf5a92f17a6459787822951908b75d14cf32d8ae7308", "third-party/nsh/tests/compat/cases/base/base-nav-page.tsx": "2e478b1af084833c2e7e54b5d0f4644f79ccf7350e40ee0efbc65880a3fa9709", "third-party/nsh/tests/compat/cases/base/base-nav-stack.tsx": "8896001549d81fc426ad065e339516cca65461987423b4455378ef1080925a4a", - "third-party/nsh/tests/compat/cases/base/base-number-input.tsx": "c5e7a009a2282f9fe2a87ef62421cdb54579f7c1827e65941f0d653d6d001a21", + "third-party/nsh/tests/compat/cases/base/base-number-input.tsx": "646a73cdcd2607e0a86f26a3ac89347bc6ec6eb852c6f6d5c86ee669507cc8c3", "third-party/nsh/tests/compat/cases/base/base-otp-input.tsx": "edc8c779d81b6d37701e2b0b75d6fda17541d9e0f69b6b8e6ff33eeac796661a", - "third-party/nsh/tests/compat/cases/base/base-plot.tsx": "d1b8be42bc6c104a3d9ab5dc236379d75244ebd3288f69e67f9b1098d8467e47", + "third-party/nsh/tests/compat/cases/base/base-pagination.tsx": "8ffa61396953cca9fe43fa4f3f41032d36816fd6f8c3ae2feddea285f752b622", + "third-party/nsh/tests/compat/cases/base/base-plot.tsx": "73f9895156ffb812cee1078c42c48fcd6fd00a5bc8b9a5eb0adee6a29fe3c8ab", "third-party/nsh/tests/compat/cases/base/base-popups.tsx": "c8eb77570887a0afbf00f4ed30a5faaa197d66aa301e69fa22334581bf54a8b3", "third-party/nsh/tests/compat/cases/base/base-progress.tsx": "b8e3871f8413e94a5a678e3686cdb24b157480d18b8918562f04962f184635f5", "third-party/nsh/tests/compat/cases/base/base-radio.tsx": "f9a6be95b008e7c8bc46ec0e89020f2399ce2c364408487a2ae22877c95a39e7", "third-party/nsh/tests/compat/cases/base/base-resizable.tsx": "7a2c43552c1bed71c2f560fc65421b3f58f87d7dcfded6eaa33ff8729edd8762", "third-party/nsh/tests/compat/cases/base/base-rich-editor-extensions.tsx": "01cb87335221e68e573136d12dfb11a6f8218c694dd2bbd81cba3b49f0e91433", - "third-party/nsh/tests/compat/cases/base/base-rich-editor-kit.tsx": "47a1fc15cc2553b0fe3efba95755460fab424f25336e439f384cbab9b6129be4", - "third-party/nsh/tests/compat/cases/base/base-rich-editor-session.tsx": "0e723ed60088cd3dc205553c4fb0eb2ee77e608bf7cd4f2b77809c49937851fb", + "third-party/nsh/tests/compat/cases/base/base-rich-editor-kit.tsx": "cfe7720d5abcfb1f877f122ea9171d74ff3d3b569c4697c16f4bf1c90ef4efbe", + "third-party/nsh/tests/compat/cases/base/base-rich-editor-session.tsx": "62b8ed216da05e3e4d10e528eb2965271efd1489c394fce44d646ddeb28a8c30", "third-party/nsh/tests/compat/cases/base/base-rich-editor-tables.tsx": "5c70a002d0f248bdbf204177d1496effed2d6e21e883e61e953217ba5a9265e3", "third-party/nsh/tests/compat/cases/base/base-rich-editor-view.tsx": "d20abb64455879119eaadc4cb36b01a417d1b2c9c72b97b8cf7fb4b891ac2aac", "third-party/nsh/tests/compat/cases/base/base-safe-area-keyboard.tsx": "66f531fc172beaca456a44477a301e8dd9b177ea85b53f9a8f952747d97ab14f", @@ -2049,13 +2149,16 @@ "third-party/nsh/tests/compat/cases/base/base-status-bar.tsx": "bbdf1ccd78c9a014eb872936d973eda83431741291254012eaaf14c723aeb93c", "third-party/nsh/tests/compat/cases/base/base-switch.tsx": "61ebec2963b6526b059e9534d2a4a564fbb9b507bc9332dc80d97aeca461c778", "third-party/nsh/tests/compat/cases/base/base-table.tsx": "8e3764bb2bcbcda963b5d5c0cd20bf040862b8e4640e10dc92a7c3a65a8ccc89", - "third-party/nsh/tests/compat/cases/base/base-tabs.tsx": "8d5f00b8e5cfc33c0ac6ab8c2ced207bfa1b2c47d9b8998f4cf04f8953f83792", + "third-party/nsh/tests/compat/cases/base/base-tabs.tsx": "f05eeb1a771bae1e3232733e5bd34931012c0444308c83af79c362a87a042d6c", "third-party/nsh/tests/compat/cases/base/base-text-view.tsx": "fc179e71239daa9b6dfd22bc597cb27707952d848899ea9c0f3dcf86ff85e939", "third-party/nsh/tests/compat/cases/base/base-toast.tsx": "d82ffc1fd405a563ae47c01b420ea8f17c3764654148566870a56a5bd65ebe2b", + "third-party/nsh/tests/compat/cases/base/base-toggle.tsx": "7f7d503ea566d3c29188997bf753fb4fff6fb553d9aa2a5f6eba84a834603f61", "third-party/nsh/tests/compat/cases/base/base-token.tsx": "37abe78636fd087039b3eec86850ebacc4e91ea264bf3f20428d97e1fc5950b2", + "third-party/nsh/tests/compat/cases/base/base-toolbar.tsx": "145ac2dad83fbdf812d102148ea755fb4010ba88fb8508935171aaef02bfe111", "third-party/nsh/tests/compat/cases/base/nsh.json": "4252ae25e6e83b77c786ff8ff5286ed99522a48fb687ddde878c25bd7008ca48", - "third-party/nsh/tests/compat/cases/base/script.txt": "39315934b6a1132d4af0a0bb4904d6c81de37a5855a9094cc31b46f76c40656b", - "third-party/nsh/tests/compat/cases/browser/App.tsx": "545bd4b950f490432e6925e99fecc3e88e38db43cebacbcf26693f90a7e10b18", + "third-party/nsh/tests/compat/cases/base/script.txt": "97253a59c80c2d46bace951133e98468a9fed1ef6bf6d93b1956d04799a9bc59", + "third-party/nsh/tests/compat/cases/browser/App.tsx": "30db527ab645f4fd7df6f665051ebc00c6736e2cf0c6369cfa3eed8332f472d9", + "third-party/nsh/tests/compat/cases/browser/base-popup-looks.tsx": "a03721b9eea94f64e8b67adfcf778e43980c127677530edc6d49ab52c5fb2815", "third-party/nsh/tests/compat/cases/browser/base-popups-desktop.tsx": "065f573ed27b6c4df9684dfeffcd711274f6b3ccb1dd23f58a8f578f19925811", "third-party/nsh/tests/compat/cases/browser/base-select-desktop.tsx": "97ecb60e491e655fa75d2ab417fbb26076e69e92b7c90f3b2c20a38387dc289a", "third-party/nsh/tests/compat/cases/browser/nsh.json": "cf16c9baa65e26e70482a5940097af2ea41afc3aced88748ca68dc765bd7c5f0", @@ -2063,26 +2166,26 @@ "third-party/nsh/tests/compat/cases/browser/props-platform-web.tsx": "12c78651a7af29d5ea404b2a82428d7117bb26ae1a699d1da9c2a292dec81dc0", "third-party/nsh/tests/compat/cases/browser/props-text-scaling.tsx": "a3825214abbfc7d43a690f835328fded2718df057ef69c57e63e1bbad6a984fd", "third-party/nsh/tests/compat/cases/browser/runtime-identities.tsx": "114ec24d6062594c86339e1ef1c93039e9fa6268d7bff538bab643cc704a23c7", - "third-party/nsh/tests/compat/cases/browser/script.txt": "e7a9db3763bd26cbee4c43b3a42d673034f2252bb83a0e422fedf95abe7df15c", + "third-party/nsh/tests/compat/cases/browser/script.txt": "d2a5583ef4db7f78e9cc6b311573d6f72fb64602d190ebfbbf928e3b42e846c0", "third-party/nsh/tests/compat/cases/core/App.tsx": "ebcfb08c5391879f94f860eba4e1aa4686d5271d82ad7bbf0820fb70752a7392", "third-party/nsh/tests/compat/cases/core/context.tsx": "947ae5df45a18b85bb511787c65323a7dffd00e980615afb580b006e8a659066", "third-party/nsh/tests/compat/cases/core/core-animated.tsx": "7ad1942c238b3232a664f041b9312487977613282edba882ec5dded23aff3e41", - "third-party/nsh/tests/compat/cases/core/core-canvas.tsx": "81e148d834fbf16626cc25590fc5b6ab1b166ba4558ed9254895ac0739350d68", + "third-party/nsh/tests/compat/cases/core/core-canvas.tsx": "34b3748e90fe8a5abe21e60b5e8be20a7860cd48efc416c5c9c5d5158fd88358", "third-party/nsh/tests/compat/cases/core/core-elements.tsx": "d9f4dd1e2a6df0693f65b7c35c6e560c5f06b35d34a2f58936567dc0fb589d4e", - "third-party/nsh/tests/compat/cases/core/core-error-boundary.tsx": "c2de89bed501a5483446830f8ec19e0a1fe7131fe0af8c0fbf8dcb6b9942979a", + "third-party/nsh/tests/compat/cases/core/core-error-boundary.tsx": "9fd329fce5838707531536ad541725bc573018cd47ed727ce5ae052b07bfa05a", "third-party/nsh/tests/compat/cases/core/core-input-action.tsx": "9e6c67b4c7cad5580219045666bd1f5d91ea377c3a371717adb9d25950194a59", "third-party/nsh/tests/compat/cases/core/core-list-binding.tsx": "b9e52b8e5f875188a98b09226d410410318846c3df9f97b1852365e063fe7dbd", "third-party/nsh/tests/compat/cases/core/core-list.tsx": "7293286aae5a7b9c04e7a1e4c25de59aae5d3d66ecab06edea6bef0da1974a45", "third-party/nsh/tests/compat/cases/core/core-motion.tsx": "1bf748de5a526d86abcde2ea33aa4efcb68b900f5e2e1fb0b86569b085892a06", "third-party/nsh/tests/compat/cases/core/core-overlay.tsx": "2174343f0431037982635a74bd3656ab1fb19e59eb25dc0bb5f175f2f01628bd", - "third-party/nsh/tests/compat/cases/core/core-primitives.tsx": "0bacc95b6781c829f28b273874cd81fcafb54b74d78f51f6d1e7e580c3e8d89a", + "third-party/nsh/tests/compat/cases/core/core-primitives.tsx": "145feff434afeb781dff958e4991ff86637baadb4cac8a0baac16dd6c116f3e4", "third-party/nsh/tests/compat/cases/core/core-reactive.tsx": "bcde98c407709730db5a6d2a620f01f322804d9184f0f727626034660ad2f5d3", "third-party/nsh/tests/compat/cases/core/core-ref.tsx": "1e7785a4a66f7e892efb5dd784de0a12a90d6aed625b251e16adcde29932c1fe", "third-party/nsh/tests/compat/cases/core/core-resource.tsx": "cc3d33c1321a04615b7fef7ac0d759fa5d76e7a3f5aae671c5ebff050a2769b5", "third-party/nsh/tests/compat/cases/core/core-shader.tsx": "3cf53530dd6f3faa7ede7caa76591c0a93fdf073e5d85509a4271d2061f19b1f", "third-party/nsh/tests/compat/cases/core/module-url.tsx": "8bf4a7a606496d4d5f321a8f722892de01e845d56bec5f42d9767ab3c516bb1d", "third-party/nsh/tests/compat/cases/core/nsh.json": "c4354909c4780abb723666fe31c2c82d50cbe3839f62c169dc0ec2f145d8a728", - "third-party/nsh/tests/compat/cases/core/script.txt": "55c909be948590a2e593436b81f8dd430715ec55dc5307763855f74cb1ad2758", + "third-party/nsh/tests/compat/cases/core/script.txt": "65d23242d6add58931828fb24a2e7e578ac38c7e32a3ad59724ea12a9474cf8f", "third-party/nsh/tests/compat/cases/core/steps.tsx": "4ccb977e817a138f09ba98ebfa5ca519f72959d3dc1b130058270f20eac8ad88", "third-party/nsh/tests/compat/cases/core/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea", "third-party/nsh/tests/compat/cases/events/App.tsx": "cec0a2e9ebfbf3c0c0b1d6e05760023d66bad7b30ec1e27b0213ad6752eeee38", @@ -2109,30 +2212,31 @@ "third-party/nsh/tests/compat/cases/layout/layout-tailwind.tsx": "cb435c0bd4bb031b2a698acb116beadd9118c714e177e37cc3847a523c540c61", "third-party/nsh/tests/compat/cases/layout/nsh.json": "99fa38b7ac5170fd2dc0b243978b5bd2a80fd5e39e4168a2cb3a0f01a58d603a", "third-party/nsh/tests/compat/cases/layout/script.txt": "2b33da49f21af33f3e5cefafe61a9a36021da80f83badc016aa26d31238ea5d7", - "third-party/nsh/tests/compat/cases/macos/App.tsx": "ecdbbe3e42fbe5641ad68a42a7ff95abd6ca87e0c5f728ef5eafb135153fd838", + "third-party/nsh/tests/compat/cases/macos/App.tsx": "9b18c87dcbd8e1de0b0f8dfe5867c56460437585c3a9c874865af73499e558ab", "third-party/nsh/tests/compat/cases/macos/base-dock.tsx": "8c9c5cf7593e25a43f45f758e1d8d018ec9ed57f15f981607ce22ad6cc82958c", - "third-party/nsh/tests/compat/cases/macos/desktop-props.tsx": "25744e1cc4b46c73761b9a09c8ab059abf97b62ed83e211e9fa27d379e19d751", - "third-party/nsh/tests/compat/cases/macos/mod-standard-files.tsx": "7007d3f22673d7d963c43fe350c1d2705e4c445c2f86a465e2629f1e2a75dab2", - "third-party/nsh/tests/compat/cases/macos/mod-standard-system.tsx": "17363588e50524931b2a21b06b7319c39864ef77c35d65cd3708524c185337e5", + "third-party/nsh/tests/compat/cases/macos/desktop-props.tsx": "618dd0b9b8a78fdb6acddf825e445954497fd4fb0f7e517ba4114ebbc57bc75c", + "third-party/nsh/tests/compat/cases/macos/mod-access-denied.tsx": "fa505e4679beb91fb465670a5f5b1b7c17831a6f7e7416eb47aa30edbdddbd9c", + "third-party/nsh/tests/compat/cases/macos/mod-standard-files.tsx": "afc8c96e9b2625508f9d5c09608a8b544818ea11911f4dccf7d6254d614a29b9", + "third-party/nsh/tests/compat/cases/macos/mod-standard-system.tsx": "dabec6f1742b7cd289aebc040f72e357c8ca551edbc561cf3547216de10d4cb4", "third-party/nsh/tests/compat/cases/macos/nsh.json": "81592a22d477fd7aa971a58119b710cd6b49d58517f6dac65e2f091567c4c020", - "third-party/nsh/tests/compat/cases/macos/script.txt": "adfe51bb062be578136db927b767640d286ee3b4e8847f3446b0cb47a5847514", + "third-party/nsh/tests/compat/cases/macos/script.txt": "c2c1c89939819b07c4b69c0d7fb5615b43f7113b8bdba931da3e5a9d3ced868b", "third-party/nsh/tests/compat/cases/modules/App.tsx": "8821d3288a27241eb02ead2c8e3f5451121f0cc0a153a3be46c1d11d93a0824a", "third-party/nsh/tests/compat/cases/modules/mod-accessibility.tsx": "17a63501cc95f1fea0c43c760f34a46579d0deb6789f05ebf1325acc055ad2e8", - "third-party/nsh/tests/compat/cases/modules/mod-android.tsx": "d8866a42fca446b506c59d9da7f78c9238dcdd56513034cc33a3c7890fc19be8", + "third-party/nsh/tests/compat/cases/modules/mod-android.tsx": "e83e61e4c90baafac221a1de2a4146787b659acb574e1eb0a91068f15d5169f2", "third-party/nsh/tests/compat/cases/modules/mod-clipboard.tsx": "82cc6f94bcdc82dc87c44b2a53f255a218e1c9fc9f82b4ebe9222bd391e833d0", - "third-party/nsh/tests/compat/cases/modules/mod-desktop.tsx": "de9c73fc7fb9356984bc87d936a3d488ed87e91aab34c8526fff5b34015ed7a7", - "third-party/nsh/tests/compat/cases/modules/mod-device.tsx": "eb4b3040a2300024eaff68102df4209adbe0593845539a373d9b9e4d6b5b9915", - "third-party/nsh/tests/compat/cases/modules/mod-host-channel.tsx": "1048ca01ab844730dd3af38ef48d1a489cf67a3d508cc1bf8b749095b424fa61", - "third-party/nsh/tests/compat/cases/modules/mod-host.tsx": "29f4a5d60b124c8f1789206ff7a90ac66aa92eddb5052278150bb11162c590fd", + "third-party/nsh/tests/compat/cases/modules/mod-desktop.tsx": "1c16d4e271037c07b391bada167b88f21256bf8806951f15f9611c24e1525be7", + "third-party/nsh/tests/compat/cases/modules/mod-device.tsx": "e305424a93178fd66160f9069239e37b67596a4d3c8ae889a135a70d92117e98", + "third-party/nsh/tests/compat/cases/modules/mod-host-channel.tsx": "0f6b6cb5f61f6fce036d9b98206fafbc8419faa80e2ff5a15ca03649f6562998", + "third-party/nsh/tests/compat/cases/modules/mod-host.tsx": "a9a21c74a47dfca26df5ebd74cd8420a242cf542b05304bbcee45e90c8fdab01", "third-party/nsh/tests/compat/cases/modules/mod-ios.tsx": "19ca5a52606eed935701564f8936cd364c8b41c95b5edf8c4f10693f7669e3de", "third-party/nsh/tests/compat/cases/modules/mod-keymap.tsx": "7a1e9e0a0e46b0809fb1f3914300adc1e43cf73bc8fc287adc18267a58666a1d", "third-party/nsh/tests/compat/cases/modules/mod-mobile.tsx": "d19999107dff2b3c0bf99e39e681c2c3409746b2c7f520c430abe01de836db58", - "third-party/nsh/tests/compat/cases/modules/mod-picker.tsx": "847df2b590f6a893bdca496606524917179805256838bc54d7f0bc8c88a3ee8b", - "third-party/nsh/tests/compat/cases/modules/mod-standard-access.tsx": "713d9927933918b5f3e6fe4764581b84113a2073defc514ad7ffca781747579e", - "third-party/nsh/tests/compat/cases/modules/mod-storage.tsx": "e28419bbd18d53dce633368de3ecadd9743f1d1bfd64a0b7c2f66eb36b6d7938", + "third-party/nsh/tests/compat/cases/modules/mod-picker.tsx": "4b8ac8005b2f85cf675b6445f38578f6861e479fc928abb731778de246b8dbeb", + "third-party/nsh/tests/compat/cases/modules/mod-standard-access.tsx": "8525ea02365ce14822c9deb4e51844c5f43dd172b154cd0b93e467bc725bcd4c", + "third-party/nsh/tests/compat/cases/modules/mod-storage.tsx": "95883dfa70675e220afeee7fc7a852bc875287a210213a680d8de56c739da814", "third-party/nsh/tests/compat/cases/modules/nsh.json": "8aded49d92a6f2cb8db2e005d097aa70f60f26e5e6639b98f45cd89154f3feb2", - "third-party/nsh/tests/compat/cases/modules/script.txt": "20f517c62415c1ceb7cf5aeb96be9b350cd13a10af5094a258741ca085baebef", - "third-party/nsh/tests/compat/cases/paint/App.tsx": "462c0a17871c5c2a0319d7fdcb48ed7847d4ad455e20c48c46e16d7a5a9c4a61", + "third-party/nsh/tests/compat/cases/modules/script.txt": "32dbe6d85d54159c91435bfd5bea80cfd60aa282a35cb1766767b0b81e1d6a23", + "third-party/nsh/tests/compat/cases/paint/App.tsx": "7bed69d697871a3605b8e51698affc4aacb1d999244f1372d62f2ec3a5b261a6", "third-party/nsh/tests/compat/cases/paint/nsh.json": "56f891ce05e7aefa7949e3445a603cb477313be037897c75acb12f136c1a85a8", "third-party/nsh/tests/compat/cases/paint/paint-borders.tsx": "f186bd434f47976a5e949ba368e0c6786269db906a38dbdb04483a20c854f4d2", "third-party/nsh/tests/compat/cases/paint/paint-current-color.tsx": "605fc020c973d74f7cfac47f284d33a32b97f212acf0a4d3da94188d62743c89", @@ -2144,10 +2248,11 @@ "third-party/nsh/tests/compat/cases/paint/paint-shadows.tsx": "9587a605b1553f6dbea4b8580c28f3bfd521f64517b8e5951ba9c6ad4f94de0c", "third-party/nsh/tests/compat/cases/paint/paint-tailwind.tsx": "d4aeb7af207b34a04336b4586b6ff046f8a6a1f1a0ae8fc61140ffdc979b8de4", "third-party/nsh/tests/compat/cases/paint/paint-text.tsx": "ced98f337efe490030f73952a2980f5c350b95eec0fb1f43492619caf7433bcb", - "third-party/nsh/tests/compat/cases/paint/script.txt": "3733fdbc0d224720fea98b9cbb7ea6ea746699e406cdf2c1babee4e7fe918503", + "third-party/nsh/tests/compat/cases/paint/script.txt": "b82ade785c9e915fdd78bee82edddf277d9edc825d143908b2001d697f2b6155", "third-party/nsh/tests/compat/cases/paint/state-checked.tsx": "901da26b71b8788c59613c30e76caed42bf06af2d03eda7f93ce82e93f2870cb", "third-party/nsh/tests/compat/cases/paint/state-expanded.tsx": "0c3afcb8825cf3fcb79f58ce781d7443a60cf45ec2560b80b836c5e99c3672a7", "third-party/nsh/tests/compat/cases/paint/state-indeterminate.tsx": "5c4f151a319a825a6d126d2a9261fbdcd8bfa15cf4559702f6ab198121ffcb52", + "third-party/nsh/tests/compat/cases/paint/state-pressed.tsx": "24be8a9ac818d9731df03726afdbb8941cdd20f1c437ef101a0eb5e091987449", "third-party/nsh/tests/compat/cases/paint/state-styles.tsx": "4c240bc5fe08adbc59ff08669c0aa52334730d7b69f1906bd329793e0f7024d8", "third-party/nsh/tests/compat/cases/props/App.tsx": "51a178c20ed71fb06cebece0a21acc5014c5471fe63664cbfceb0af68f4850f0", "third-party/nsh/tests/compat/cases/props/images/flag.png": "9d0f5b97591776503e613b5af88ea88bf6d60bd512f67f1972b56419dde9d997", @@ -2172,10 +2277,10 @@ "third-party/nsh/tests/compat/cases/props/props-scroll-behavior.tsx": "da54666957ebfe73a402c4062dd2a3717a3b0e98bb78bd97178df9e1f804db50", "third-party/nsh/tests/compat/cases/props/props-scroll-zoom.tsx": "8edeba02c696c9d329a305e25656c7d0cf5181f2c81c115b17cdfcfc4d43572a", "third-party/nsh/tests/compat/cases/props/props-text-lines.tsx": "e7b7772d0db91c998505f960e2c6d395acf724a54e4d34e7ee99a07b826a1be8", - "third-party/nsh/tests/compat/cases/props/props-view-accessibility.tsx": "13eee4c2436a07b7728a05a7c59b1534bb265b13d058e340ab04fc9a1b70f9ef", + "third-party/nsh/tests/compat/cases/props/props-view-accessibility.tsx": "4a7f00857ffa775d8096e37477f2b33f2c9ee1f4d71c3dc2a784a909c06e5544", "third-party/nsh/tests/compat/cases/props/props-view-styling.tsx": "090ecc1a56e15805b1050e0d738efea596471ef83f77cf9039f9a098e8cafc15", "third-party/nsh/tests/compat/cases/props/props-view-touch.tsx": "ce558a87caec6285761534e74052e946d5e07fb2f1457f284de7f421e7388a15", - "third-party/nsh/tests/compat/cases/props/script.txt": "3dacf716600d4a831a4e50ca523c279c2a7ae390b4c112e5be74971e8cc27093", + "third-party/nsh/tests/compat/cases/props/script.txt": "da45312ecf8e94c7e9bbceea3db9753c2486bbb2fa420885b69e479eb9d9db48", "third-party/nsh/tests/compat/cases/props/svg/badge.svg": "4c54331a184060435bbbbfb08cbb87853f7993eced9e41da4d73ea9795ba0964", "third-party/nsh/tests/compat/cases/props/svg/mark.svg": "3d466606db574e99f4a4fc4d5d16b0f21ef67c57b4237f52a44c6a931f7e042c", "third-party/nsh/tests/compat/cases/props/svg/star.svg": "29fcd6276b0ffac4e8007306c2a515d8a98f8a7acf4c81d229f367c5b6227dfb", @@ -2237,59 +2342,67 @@ "third-party/nsh/third-party/icy_sixel/src/encoder.rs": "9f2941a073f3df5d1216bb4f71487a3c43b5ceded1ec88cd13a804b4d7e31999", "third-party/nsh/third-party/icy_sixel/src/lib.rs": "126c4edada7c430377a73839c9da93cae95f9191be69c1401fb4ac1cad9cbf5e", "third-party/nsh/third-party/icy_sixel/src/sixel_image.rs": "1e968370dd0ff8c69751213a1ed11cfce6783feabb30c2b5074ac1c4ba3d8503", - "third-party/nsh/upstream.json": "3d796741ff466d5e7c802944af4d0a1e9722872deca08fb8fc33053b39c4dd79", - "third-party/nsh/website/DESIGN.md": "cc0415e081b034529e7a7029eadce18f31eff1bb3214b88394cec57a47e0854c", - "third-party/nsh/website/README.md": "90263bbcd74f6b2d13d5ebc56727acb1693ed38fc098ba91e4624fb5da8000dd", + "third-party/nsh/upstream.json": "04afa53ed679b927e62af73df96e6cf3244f3360c5d6ddc558e01633fc2a6327", + "third-party/nsh/website/DESIGN.md": "b438b10950084d805892676d3e7940301dd14890136b04a0e1b93fcd3361f7f8", + "third-party/nsh/website/README.md": "2e5e02ee4ca4f84972826d8199ef1e7ba53fe39815ab3bb317f5235f33713b98", "third-party/nsh/website/astro.config.mjs": "39c2c6f4c787d9480ff2c2464d09c683f8938089ba304af83f71291dce1f379d", "third-party/nsh/website/demos/counter.tsx": "7f76822193acfc307b253aba5459bb17ee82a2a71abb0d716d1afd7e2463e457", + "third-party/nsh/website/demos/el-accordion.tsx": "6807900645395a0625a10f743303e7e3d58a99cea3168303c09bd23601ab1440", "third-party/nsh/website/demos/el-alert-dialog.tsx": "65ec571976bc4205eb503fa9216c3d2170960b6dee3b3488e9fdcd1dc91e3d1c", - "third-party/nsh/website/demos/el-button.tsx": "fbe996b0b583566a2d9cd93f01230ea02f1aea97ecc786d031c1c4421f25c1dd", - "third-party/nsh/website/demos/el-canvas.tsx": "61030055c84db9db4740374291b9ef1bc9c37ac7c7e0fe3d449a21a91a600922", - "third-party/nsh/website/demos/el-checkbox.tsx": "3e79ce97488b9cc16a4572420f294ed34bbefb35e0d46d231c3ac9a586afa6e1", - "third-party/nsh/website/demos/el-collapsible.tsx": "d10c9e0201c16729ed2dd2fdef40601215c8cc9f8ae00ad731712beb6e0f9ecd", - "third-party/nsh/website/demos/el-combobox.tsx": "a7ea2f6a6d342dfdda1853a4ef45d2e133704779dfdfd3381eadc6a050eaca74", + "third-party/nsh/website/demos/el-avatar.tsx": "8c24b537218498d7f48ebec0366f36e5667d900e043b74b942ab7bd45444215d", + "third-party/nsh/website/demos/el-button.tsx": "e6552b0372289986d5f501b07f425d9285cee0075f2e9c91ac0a0bbb0a2d7080", + "third-party/nsh/website/demos/el-canvas.tsx": "4ef2e3a58b97e047ba0c85bad0b5e7c0ff4b0db260b2ab13b43fa0410a3d139a", + "third-party/nsh/website/demos/el-checkbox.tsx": "454494a39b84785ed467783ab78d768127bf6ea7c1357dbee9c19f8197974499", + "third-party/nsh/website/demos/el-collapsible.tsx": "3a4d0149044df919f7334ff81cfcbac0516d9ec40ababb42559488edaa709a69", + "third-party/nsh/website/demos/el-color-picker.tsx": "9dab526a1e193dd1b393924df6417f181268c9f3a352b021d51832e364e682b8", + "third-party/nsh/website/demos/el-combobox.tsx": "3bc5829314f1d1be705299ff7a899e3c5639360e72a1aa21f46904533ba7618c", "third-party/nsh/website/demos/el-control-flow.tsx": "cde2d1d1ce81080c618eede61979ab32635b9a374a3652f0d970d8ab0f88acbd", - "third-party/nsh/website/demos/el-data-table.tsx": "a2b39364f1b8b23933842a5a096b217283713ed0218b416c0ccab493f971651c", - "third-party/nsh/website/demos/el-dialog.tsx": "d8c2ec7b8c5c689a7c0c8f6f3ec1e33f83382040e2bccd2a6f4e5f7a8f655b41", + "third-party/nsh/website/demos/el-data-table.tsx": "f8402183a07960d243c8cdef1a90023d177ba0d57bb8a1a9acc0bfad7fb8bb89", + "third-party/nsh/website/demos/el-date-picker.tsx": "c307da905a175d8a00838d57ae629710460712a9428f811fdb77e45cb764d43e", + "third-party/nsh/website/demos/el-dialog.tsx": "afcec98e62109d4257453bffaa8ae9867b6fbc69138a8513a64126069e438947", "third-party/nsh/website/demos/el-diff-view.tsx": "608a2f75be8496ab0e92411151716018dd3b35c6484e6d5e6a9873265519e695", "third-party/nsh/website/demos/el-dock.tsx": "5f011958756782f3ad8ae639b01da0288903ddc6f4e7f7a45ddad05b7037357c", "third-party/nsh/website/demos/el-editor.tsx": "3998d0d0c38439663b12adcd21d01ae1d99c98cb52e5bb9dde811083ab29e6cf", "third-party/nsh/website/demos/el-image.tsx": "3383954e7035325cf4dc3c59508740f82019e8e58872ae28129b9fd856684937", "third-party/nsh/website/demos/el-input.tsx": "b0f29cccb242909999d1ebfd5833b7d64037f6d9366b748d44cf367bfc830bc7", "third-party/nsh/website/demos/el-keyboard.tsx": "c6ced167355f730a63e359d6ad77b62b4b952783c18fa31ecc5345d10fea45c1", + "third-party/nsh/website/demos/el-link.tsx": "75aedfcebb0f395b3e6c184ab57c3d239ed858bcc8cab44cd9650c6ca8d92f50", "third-party/nsh/website/demos/el-list.tsx": "0a8312b3e2e5a6308f438c40800600fc6c131468c3dceb149957964258f0dfdf", - "third-party/nsh/website/demos/el-menu.tsx": "5684f3f516be9d206ec01905e32a04ccc01ff312a65743218240d26c63aab1c8", + "third-party/nsh/website/demos/el-menu.tsx": "6e35c1cfa852680a3cf89f9a43cbd8da6bd2291517c36af758e2e42fd47dbfbb", "third-party/nsh/website/demos/el-number-input.tsx": "2bb5480062293619ce5264f7a11e4b9d17d41b25db03738d1166bce55aa1b3e6", "third-party/nsh/website/demos/el-otp-input.tsx": "2449808cc0492bbac620191ccdf5f9272cd43d16feaa77ced0fdca95f03e7252", "third-party/nsh/website/demos/el-overlay.tsx": "7e1dfa1067091ca88d627a552d7c0f81cd1a4a11f06c6bdbce934a5f7b8edf4b", + "third-party/nsh/website/demos/el-pagination.tsx": "55e695bcef3c394ac24a9b076d6c239666e7ac17ffbb89bdb620772b0e98f95a", "third-party/nsh/website/demos/el-plot-pie.tsx": "ba9af6865ce4acee01628168bb3b986fa352b4357e6aff7c2bb5c60211fd8e31", "third-party/nsh/website/demos/el-plot.tsx": "b9eb7c244d0c88cbaea0abab7838ad6686f9f08529d2fd217706f10969481525", - "third-party/nsh/website/demos/el-popover.tsx": "f480a004f8a2c3ab753662b27eb3719da6e0b3c7859ed18af0e2234d64f53314", - "third-party/nsh/website/demos/el-progress.tsx": "38aa917d92cef498f2247966b935be97cacf31d0a843022ab957c17ff4d49706", - "third-party/nsh/website/demos/el-radio.tsx": "d4eee89510ff1f06d64265f7acc114ba857dc2371ecea1a38cc0b6803dcf58b3", + "third-party/nsh/website/demos/el-popover.tsx": "b8f6be2ffe369dac8069b6b92e06d22e67b04bd7164dd68b5f1f2644c45d2fc0", + "third-party/nsh/website/demos/el-progress.tsx": "5bc473c44968d1b1fcaa6e678494212583d3705913ec72f7d5c09018c6144b93", + "third-party/nsh/website/demos/el-radio.tsx": "3a795b69ce0d594bf558d91c4041241a0c3d7d51f7cffda9b369a3d3c00f4007", "third-party/nsh/website/demos/el-resizable.tsx": "dffb448dfbffff9980bfea51161c0ca43263b8691a3a573ec5a53dc5042e84bb", - "third-party/nsh/website/demos/el-rich-editor.tsx": "db6367ee6a299f834a338d738fffd241d7c79a7f593b2c07a14af5fdf6309e43", + "third-party/nsh/website/demos/el-rich-editor.tsx": "52d44fa4a71bdfa1ad00f5ff5983c118a3e9a57bdd5eedbf97e636f08ad3ca05", "third-party/nsh/website/demos/el-safe-area.tsx": "7ce7b52fa409142af5ef4be888ab03f7b468324a934459835891a84dbf980669", "third-party/nsh/website/demos/el-scroll.tsx": "fd75f6d98a2ed2b43c30592676f4d136db41a5c7e60ada5dac20f8a9c241fb6b", - "third-party/nsh/website/demos/el-scrollbar.tsx": "53eb54054fd3195e7668f4e78fddd25faafffd0d631ecef219c93203aa336531", - "third-party/nsh/website/demos/el-select.tsx": "554a8315d3b8a723d6279e44b80027021134563b0002f5e89d6bf16fc9d13567", + "third-party/nsh/website/demos/el-scrollbar.tsx": "6ce786c29bd97196a33cbe01dcd305bdcc87c40de986ee79c3093d5bd174261f", + "third-party/nsh/website/demos/el-select.tsx": "7caf8423eafb88865207a9db21321f91dd0d52151934bf9c5c5446d74fa48f88", "third-party/nsh/website/demos/el-shader.tsx": "c568573648b37cd8bb9be6cc4676fa7d816f83b3dc0b509fc7078c81131e739a", "third-party/nsh/website/demos/el-sheet.tsx": "a6e0a292a2ed29e77a75cfde82c4328f03a4d99218a9596e6cc3247ef0692dca", "third-party/nsh/website/demos/el-slider.tsx": "6bf35d22775c9c1c77001160085f64875bb3351c06d31bbe1ebbb0764a8925ba", "third-party/nsh/website/demos/el-switch.tsx": "56e73604db56f953bc12e3dbb429ee8bd22d92519012eb0a9f5395709f5b89ef", "third-party/nsh/website/demos/el-table.tsx": "28c577166de88e2da37f83951958d7233acc1f0334df72411ce2b47f18d88592", - "third-party/nsh/website/demos/el-tabs.tsx": "c8bba5f304251836c9706c89834fbc4ffe16a1edbabb56abf24eccad5086c8bb", - "third-party/nsh/website/demos/el-text-selection.tsx": "37bb519c59adf25b0974b4ae6d0554f3eda800df7a66137a25a7025fc4104fde", + "third-party/nsh/website/demos/el-tabs.tsx": "ff18c84d89904a1a58b7f62211158a938748c8118a4b2560eec10454703b6d1e", + "third-party/nsh/website/demos/el-text-selection.tsx": "21162c1039a24681d65322377df1e34b4ecd9d3b0178b773e87275ae4f19a7ca", "third-party/nsh/website/demos/el-text.tsx": "dc72585e809665ac7843e7cbd16f2bcf2bf5ed67605452455531064d60dcde0a", "third-party/nsh/website/demos/el-toast.tsx": "bfc50ff54091430f5a62e490309376ce76e9542bc3de60b8fb7e568e58412ed2", - "third-party/nsh/website/demos/el-tooltip.tsx": "cabbef6674016732e4c353ae036e9d58f2bb4a76d2cdd15e7579befbc101f8fa", + "third-party/nsh/website/demos/el-toggle.tsx": "da5f492f8cfa53b9d9c438fef8491d404cd8ccc9f16092381201185c81f34e05", + "third-party/nsh/website/demos/el-toolbar.tsx": "0365346a4e7ebf3eec6a79fdeee6716a7dde37e958e80340e224e2c1b65cf118", + "third-party/nsh/website/demos/el-tooltip.tsx": "124685f792a8bf3d62b40073bd99e73b9023ef2c7e267b23f50abbbb0acd419d", "third-party/nsh/website/demos/el-video.tsx": "71e40833bcae5447c5c98349ab3132667098bbc328430365e75d713512ad0467", "third-party/nsh/website/demos/el-view.tsx": "4cbd6847785931518328f8cdc19cac588a54938476eefa1863b4fabc77cbfb14", - "third-party/nsh/website/demos/el-web-view.tsx": "158d5b05fc7506e693f7ff37ad91b21b42e84c5532f5f64f7fea52a8f92213ce", + "third-party/nsh/website/demos/el-web-view.tsx": "038adbda1a504349bb0b2c701f3e7c9d9ec8639a415ca2cec5628daa34fdb901", "third-party/nsh/website/demos/guide-animation.tsx": "34ced8e54b5e5a2a5847ea58cc0ccc2e98fe3c38c18f8187757cf670a8d29f8f", "third-party/nsh/website/demos/guide-canvas.tsx": "ab507752e5bcd0eb1ea33057bb41872893a1ea62cb94cf456bcf00d26310ffdd", "third-party/nsh/website/demos/guide-components.tsx": "ef9eca4c8815af23e1a50bde4adcef7169a10914bd6332438fafc1eeff53a423", - "third-party/nsh/website/demos/guide-device.tsx": "0c80ece817638c0bbf6da7d557ca66dfdfb3eaa58a54cc7b7c5490463c57fc62", + "third-party/nsh/website/demos/guide-device.tsx": "0d88ee636efa6cea101963554e7940da1bcd9348b69d6890b1d0b41c291c6789", "third-party/nsh/website/demos/guide-forms.tsx": "5c7e64909a134feeb49393d48b915661c71467ffd0435b5b577fc2f70e509782", "third-party/nsh/website/demos/guide-gestures.tsx": "a326dcbd52a62e8e4963d4a092e5400ac35f7bfbb18c455bc71a36c3943289b0", "third-party/nsh/website/demos/guide-getting-started.tsx": "51925981508c05dd75a3af29fc8b65fb1105396187245f567a108b4298c8c9b7", @@ -2312,22 +2425,22 @@ "third-party/nsh/website/demos/mod-canvas.tsx": "07682ab85724a60deec18f54ea31a8e2f5ff8e457c5ab17e7182ede6d434a381", "third-party/nsh/website/demos/mod-storage.tsx": "7c71895224a551e3261d26c19e4044b0a149f287636a309b98ffd8a13e214b49", "third-party/nsh/website/demos/nsh.json": "850efcf429fec8b3429fa34ee41a4958dd4d5f45aff9f13fc7dcbcdec06eecc5", - "third-party/nsh/website/demos/palette.ts": "5b4e7cb12325638e07032eef0373563f4c306b4b5aadd393df593f8429bb80a3", + "third-party/nsh/website/demos/palette.ts": "02231bc98535cff8c209a0670d08f4f96bb0bb86705f3c3e636b7f8fb2d660f0", "third-party/nsh/website/demos/text-view.tsx": "a265576abeca9f4efa1b95d1b57f4424aae2753ff80a5019dd6728a85ae21cf2", "third-party/nsh/website/demos/tsconfig.json": "9cc30d6cb1f4394c49a8aaf0e4e0126a9a137ff98e2e102ba38a8cd405689dea", "third-party/nsh/website/demos/ui-navigation.tsx": "c1812b834af3e8051c548498516ec65b7bade3fa327c3abda240f4f48cc9920f", - "third-party/nsh/website/docs/accessibility.md": "82eaf181d0f6cb1d8bc40ec9a2c23c207c3d163612fe6bc6a0a995cf9686b379", - "third-party/nsh/website/docs/android.md": "31a9027cdc33650bc260da0ca441317eb04e9bc1322e1ec8dbdb0b56be75cc35", - "third-party/nsh/website/docs/animation.md": "bf9474dd71ede504525e6eeab38a1f691551b3652fb692368102e541dd72d46a", + "third-party/nsh/website/docs/accessibility.md": "0f504242ebec73eee1fe7cd7ae741074048644474f62fbf46edfb19cc4bf662f", + "third-party/nsh/website/docs/android.md": "0e8dcab076c0aa595ac1cdebab55086c22b62be2e3984c825694aee4944f3b19", + "third-party/nsh/website/docs/animation.md": "87a276d211b5355370a2fa64d842d1e21bb6e3a6e9746f8336d421214e59c2e9", "third-party/nsh/website/docs/architecture.md": "03fd3f0b9b408f5b58f8fc73575994e1279999113c4e2b51bf2105e1ff088788", "third-party/nsh/website/docs/canvas.md": "a49acbd4da5a3c5f2f081e0f662f73623443943ecac3a5576f566aa087eb8c4e", - "third-party/nsh/website/docs/cli.md": "4d51403a00ca53f1bbf6e137f0d2cd17c483ba67e62f9cf8cca59a99c401438e", - "third-party/nsh/website/docs/coding-guides.md": "85edd42a5452bc2a9052e242be65c6c026654cc16663b7d2186eb62b6492827d", + "third-party/nsh/website/docs/cli.md": "f395e3cdae4b2c8816c33a4ed931f9d77717ea7999b0a27c3e4c4d50571b935b", + "third-party/nsh/website/docs/coding-guides.md": "221afca7c1c61f24685671ccf1413b94acda712cefa61ea5738311d38e1a2af9", "third-party/nsh/website/docs/comparison.md": "80f11e255cf3463f29d5610213b57808e1ffa92c5de021ebff9e9e153455dca5", - "third-party/nsh/website/docs/compatibility.md": "eda83ac8b674202700e00588cf9f1ef4d5c219ea4b5cdb8fa575f3e603949288", + "third-party/nsh/website/docs/compatibility.md": "431c24b579dd57baeb7957c7dea8c56a0a4d1252e5fae26c6e67d1c76bab0f0d", "third-party/nsh/website/docs/design-guides.md": "4296bdbce6ed3911eade14255263eedaeb0c757554ef27ea6158535b593437a2", "third-party/nsh/website/docs/dev-server-and-debugging.md": "bce9f1fee0870b274ade7bb42131ec4b6c126062cb4b43c1c1a8207e47d423a4", - "third-party/nsh/website/docs/device-and-environment.md": "104dbe10046928db2c14809735712fe46354afb8e727b84542186ef7be1422fa", + "third-party/nsh/website/docs/device-and-environment.md": "5e049a4c16819323fea46b14d56f88d5087d154399fab370ae9cb1d7ebc748b7", "third-party/nsh/website/docs/fonts.md": "0bc869c72523762c03156078e995c1cf176b253180f24c4fae1d5cfa02f4e65e", "third-party/nsh/website/docs/forms-and-keyboard.md": "bac217a1cdfcedfe9439bf2eacac1983a677b54e83013980208d5c00e4210ff9", "third-party/nsh/website/docs/getting-started.md": "57c002bb7e893547e7e74dbdc0f3c0b5feb0b733fd2b5c3ab74e2626db9b411d", @@ -2344,88 +2457,98 @@ "third-party/nsh/website/docs/lists.md": "fb4ffb925cc99200ffe1218c40cffd447cb27be905e95f7abb91322563fb74f1", "third-party/nsh/website/docs/macos.md": "1b63a5bad9780b73b6f32c8a0724d80e6391f6d084e3ace20b519074ca3595f0", "third-party/nsh/website/docs/native-components.md": "24cead0151f1b031e32c770f8bded2107e2491dce22c19f69def9b7cccafce6f", - "third-party/nsh/website/docs/navigation.md": "cf20bf77d61d29d3aa666fef57fbe3272059599f77f18db70c9625650a412f51", - "third-party/nsh/website/docs/networking.md": "b9ff23263dd2d9ae99a61bf768990be2be66d095f918dbf9894b882ead0bbefd", - "third-party/nsh/website/docs/packaging.md": "383cac4dced8e062cf4832e05ef8ea0efe186b80d4cd6edcea9e3c41c1e3e124", + "third-party/nsh/website/docs/navigation.md": "4caf79018c6a07eb6d6d26274e0ab080a508dc6f60855df69ca52996590b959d", + "third-party/nsh/website/docs/networking.md": "76dc39f89e08ad40e16c639f3c22162741842f56a3f2ff953500a7db22e1ee5b", + "third-party/nsh/website/docs/packaging.md": "184b6b75df52eae4ecc525a5c49833871ffe8e9a6c6cee429ff822557db04fba", "third-party/nsh/website/docs/performance-report.md": "8924d78b30d232996c0f6a9d3cc21c93643c0b9a8e95a6dd3235f316a9f854cc", "third-party/nsh/website/docs/performance.md": "f8f220699bc54685faed27db28a73127698cd6a3c55ef3f99c4c1d14484eac8a", - "third-party/nsh/website/docs/platform-specific.md": "f29e51af398f7c9f3ac01be8afb7e7193ee31739dbaf595a529a742aa211a851", - "third-party/nsh/website/docs/platforms.md": "958fc635a464380cb3f13fc8a9f8afde809ded16ae0913552ba7c8ff3ed828cc", + "third-party/nsh/website/docs/platform-specific.md": "76a993a2a0033f2351b86754f02199e7f8e3a7f9b8a3af4b70aaff83a3d58076", + "third-party/nsh/website/docs/platforms.md": "dd80dfdbcf50423fa2da07a86aa018ca310e288c597aecc77b3fde5d63cf0bc2", "third-party/nsh/website/docs/project-structure.md": "ae14cd2ecd8d856f23b409ba21e68eb8690758cf35b992642516c1807ed8b5b5", - "third-party/nsh/website/docs/reactivity.md": "3508148055b92d8105620ddcc07da3d43edd39e6e5532db835a18fdad3e749b0", - "third-party/nsh/website/docs/rust.md": "83c454592aeacde926459aa9cd56c445597571b9893dcdf2e66a08d1b808758f", - "third-party/nsh/website/docs/sandbox-and-capabilities.md": "9bde8a7a760fba97dc3fd0c0c3252e25808e6d491b6ee71e814284ce72918e93", + "third-party/nsh/website/docs/reactivity.md": "58faf0c4997a1dea0f6ed14553621b1c5b44380daec0c8b8a97720ea646eba6f", + "third-party/nsh/website/docs/rust.md": "3b582ec92e69733ef8f51de463e066076968ac48dcc102a504f3e22eb1bdc314", + "third-party/nsh/website/docs/sandbox-and-capabilities.md": "aff6fbfa517f32dee574ae008a2488a174ee0c15f669150b11e4b0b871870551", "third-party/nsh/website/docs/scrolling.md": "9701cda661979af8667c416d5488a7f06d052834b179a98c46f4bf3526526ae8", "third-party/nsh/website/docs/signed-packages.md": "203da373bbcf21d84240b2626d91860e4f3d745733cbeebc22a42a042d0185ee", - "third-party/nsh/website/docs/styling.md": "25b06d63dde3b1513bc5079e9c3a766afc192412ffc07f7188a409e3487a9d10", - "third-party/nsh/website/docs/tailwind.md": "cd7412d1b7efbeecc364690f98e1ee0353549f65531d2a1bf451cf02f3dae52f", - "third-party/nsh/website/docs/testing-and-trace.md": "d1c2f61f57744f7091021528e980005dad7423d3ffb2ba35a3c3f004194eb54a", + "third-party/nsh/website/docs/styling.md": "c3b673df4846a33373198fd51fa935a63983279afa5f80845bd47deaef482fe3", + "third-party/nsh/website/docs/tailwind.md": "16819423621b64557eb62eed3a34a5239d24d0004679c264894872969f6f16d6", + "third-party/nsh/website/docs/testing-and-trace.md": "58ba4e913a937f134b4873ea6b3f5c0130c789261e371d83ea4cd89685f66c7c", "third-party/nsh/website/docs/text.md": "3f95b71518de2c23762d8b2dced94474eea323e41f3e841da23894d4174573d8", "third-party/nsh/website/docs/touch-and-gestures.md": "6242bc81df43076e06ddc25e8006a9106b6ae9ceb895ea94488a7193b12a129c", "third-party/nsh/website/docs/tui.md": "4958dc50a369884bc85075ee840e4bdf57a224a8ed8eb7f6352f86a1d650a2af", "third-party/nsh/website/docs/web-view-and-video.md": "7ca45b25714c2c4759ae7e5de93295b9078754c98ae7216de12645da836e2c5e", - "third-party/nsh/website/docs/web.md": "de32f726eab8abd0feb05ed254423347eccdd46a9cd07ee9d248efba8853b242", + "third-party/nsh/website/docs/web.md": "d216919606d8c8c80569bbf14ac16ba9065d0853174edfa6472e25e7de9f914e", "third-party/nsh/website/docs/windows.md": "004be3cf57a48a0128ab1ff3b43914fd454a80f8737c65251d98a75f708e831d", - "third-party/nsh/website/elements/alert-dialog.md": "c161e9f312ecf0026ef36e9a902541c5b00c315168e7d45b7c0351ab5a6385aa", - "third-party/nsh/website/elements/button.md": "623e7924f4610eb2a67e8b77422dc22ce1c230f7d71ea47c5a37f48737fd4d6d", - "third-party/nsh/website/elements/canvas.md": "296fcae8a01f14a2f40a7c298945cbd2f43d90d12ca16c1a53f4a2e96a5008c9", - "third-party/nsh/website/elements/checkbox.md": "51ecb93feb07201be26c2a8426cee8ab8bdf8c31df0f651ece34e3d435d526b4", - "third-party/nsh/website/elements/collapsible.md": "d50bd94a5c4e52d99ddb9cf9fd6d72e8176c8efb9064b537a5fce99214f720c2", - "third-party/nsh/website/elements/combobox.md": "72de91fdfc4e47d01dc24c25e376e4dad6e1dab864b837d383d74b167a3adf82", - "third-party/nsh/website/elements/control-flow.md": "976e6e9d24605ee865be5a0d80375e57f705b8c4a273273536abc156703deade", - "third-party/nsh/website/elements/data-table.md": "dc90ab930a6062783a2267230ec274d4394cfbf950d3154df3dc78c234e94153", - "third-party/nsh/website/elements/dialog.md": "ed7a6058d73ba01db0d53b00b92fdcf44d55c6d2adf1b1ad59ba1804b499bdeb", - "third-party/nsh/website/elements/diff-view.md": "92c51dbf8ee5f74704023862f225030ab5dd8dc6b78d73c9e145d07acf955cf0", - "third-party/nsh/website/elements/dock.md": "8f0c3f9cbe4c6fc93097530758c297c8a216735c56f7660fbf57979ab03b875e", - "third-party/nsh/website/elements/editor.md": "3a7ac0d834f39c1253fcd9c83fcf2d70b9818b069f67554cd1973f1492efa661", - "third-party/nsh/website/elements/image.md": "917d94592ef65d822b8cd9b3abc773b1b29555e76a3d69b76118329f56d62159", - "third-party/nsh/website/elements/index.md": "0c9e0f25ab4c8a7a8bc7433402bbe76fedb9c6d8c990032f19aa5131ddc7d1e3", - "third-party/nsh/website/elements/input.md": "36d48ebe18559f36eb2062174eb65a3140d10e819d11a8cb5f57425982ce0228", - "third-party/nsh/website/elements/keyboard.md": "05845689f24f1d65428a35a6a780369c537f6e0be15155ee04d52c247be2f58a", - "third-party/nsh/website/elements/list.md": "a3fe8ae09372238e8f05e8067bb5e549c2d9ca96ef048501d3780a3e103d0d73", - "third-party/nsh/website/elements/menu.md": "71cbebc03199e01aa10e90633b6b2f81716b0c3fc8862f625363a3d7c6384895", - "third-party/nsh/website/elements/nav-stack.md": "27c342667db440a1287357a689724266e02e8f02de42b3e496b8bb4fc4fb4508", - "third-party/nsh/website/elements/number-input.md": "2a58726dbcda4e5ff61fffba61b26aa5edfc76c7ed20826faaa1e26835f973b2", - "third-party/nsh/website/elements/otp-input.md": "1904df03c5b497eadf843400268f59bed139975c5054e25791d6a752345ec70b", - "third-party/nsh/website/elements/overlay.md": "97be7e18cdb1912880ee0d5fdde848fd7ed4e413f4b970eedca32c750a01cd22", - "third-party/nsh/website/elements/plot.md": "6ba7a735148a64c77c392281118ed22f86fa0367a39a0c501c0a78124aee0175", - "third-party/nsh/website/elements/popover.md": "72688451f092a4377bdf640b96d5e1cdafbcb844c101870414c6650955fe8d63", - "third-party/nsh/website/elements/progress.md": "7cf8a56aeb798c17eb5eb1af8dc2b29b6d5c522f6e262d27e5c8b91e6e6d304d", - "third-party/nsh/website/elements/radio.md": "800d2c2be28552a923027f809d5eec4d86be0edaf410b818e9aac63dc11d8537", - "third-party/nsh/website/elements/resizable.md": "d4dbc581f1ec3988eb4e7cb10dff3f75a146a26adc64392c4cb580958d499ee4", - "third-party/nsh/website/elements/rich-editor.md": "49da0418f815f1a41da62bceb179e8311eead72f12139e299adb07e1bfde54cf", - "third-party/nsh/website/elements/safe-area.md": "2ac5f9d7de5924f74d5d8738d82d3cd5922efb7de9a3a655c00b8bc920217ffe", - "third-party/nsh/website/elements/scroll.md": "705a1ce3fca4bf420cd9d704aeb0c1fb811df4f0f3f5afe3b43a46ad2a41ec1b", - "third-party/nsh/website/elements/scrollbar.md": "58f19d63c9bf452a2ef37cd1184a7e56c0dd8a0d20e3549df97cec94da62b02a", - "third-party/nsh/website/elements/select.md": "3aadd0a5746a03f6094d41a4ecb1bbe340cedaa08a0b83cd473bd4198b371f13", - "third-party/nsh/website/elements/shader.md": "b73858da7a24f3e68cb7538dea51e284a95490387cdb23cbecb928d7d235f16f", - "third-party/nsh/website/elements/sheet.md": "32c206dda2d71709fbe53cab7f8c8efc300a18baa5ccd1fbf056a56edd72d33c", - "third-party/nsh/website/elements/slider.md": "648dd2d7c2eaf1b0e95d3118f516360dadd7a8869b3daba87d225193ee00a155", - "third-party/nsh/website/elements/switch.md": "b36eb79a0aa06a5428e29d9a9c31aa09279bf05f6e87d4147e51e14df292fbf9", - "third-party/nsh/website/elements/table.md": "59dd012bb619b12474db44b18e986993cfaaa190c4c58a0d5463f35804a7ef06", - "third-party/nsh/website/elements/tabs.md": "a9ae0969212774c117994ee6078c0e7f79c22ddfecf25a2be43fce615ac3a708", - "third-party/nsh/website/elements/text-selection.md": "7b206b33c85e40b4b26f7af6055a94c80266d7833a1907e274ebe4d7c02c0435", - "third-party/nsh/website/elements/text-view.md": "76f0b153239cf398b25e8d3d52d209a87603ef92d4e2c61fd016bc61b7b6f8d5", - "third-party/nsh/website/elements/text.md": "a43e2a2258b88189b7ec9effe5f3edacb87121102a7bd37c33bdb460cc678c11", - "third-party/nsh/website/elements/toast.md": "ab9509f5baa712faaefd688fce3ee058b5fed23e61d1cd7416af3c0ab2b87c4f", - "third-party/nsh/website/elements/tooltip.md": "9d34595d6806682d4ceec1c557e4a9709bff34eab8d4281743cb99cf517c5893", - "third-party/nsh/website/elements/video.md": "5cb6b796c60ce5efdb3a0dd103bc634fd90d2382ba875e696655c5b7c592de8a", - "third-party/nsh/website/elements/view.md": "1d681d07b3cf0ca1b58dd997b9a5217f9fef8382881d172df03c4e0f3f828307", - "third-party/nsh/website/elements/web-view.md": "cbdb016f701b1f6bac419479513f43406d2e3ab5a79a6b19e7aa424d224a7462", - "third-party/nsh/website/modules/globals.md": "82b65c99724569b34d90dcfe1c155ed0e15abfb142a4a691b62042d632b9f7aa", + "third-party/nsh/website/elements/accordion.md": "fb5b5e196c111b08de64649b8b4f27a9a3b86d53f753dd5e36132dc95e48058a", + "third-party/nsh/website/elements/alert-dialog.md": "26dcaa8ac6db98864f1bab0c046dca6d1d2836e344d682dc97dca0dd5cc268f6", + "third-party/nsh/website/elements/avatar.md": "a5b2824c8034413337d337f29994835c1fbe3b2db72821f5cd997542918bc210", + "third-party/nsh/website/elements/button.md": "a1164fda8560a04a94270bcf0565e8717dd0a2390d7daa70a87ab756dd92ca39", + "third-party/nsh/website/elements/calendar.md": "dd4516bdd362a28f79a26f8a1c8b5f826e94fe2f419a8e18cb3bf4c450838159", + "third-party/nsh/website/elements/canvas.md": "db2829bf8681ec523fd833ce1fbcb6705ad3c37aa4684afc075004c4351c67d9", + "third-party/nsh/website/elements/checkbox.md": "abee3f14e7dc60b5050bd0c72c3a7526f3514defd11fe085f07696ecd173d06b", + "third-party/nsh/website/elements/collapsible.md": "cf0c6bafa8f809201f9b2b24ff0fd99c4c4546cde694f215171e2c6da5d9042e", + "third-party/nsh/website/elements/color-picker.md": "32264b5b56e52c9771ff2d65093f741bc0bb47e8becb1e05103380fd7364ed86", + "third-party/nsh/website/elements/combobox.md": "2fe84df08ce57b2e60eba80cec4be92ca1292d063b2b8910bdc3503af4afafa2", + "third-party/nsh/website/elements/control-flow.md": "f203fe49580cd892a8752d4eed4aff241b76e3e5c9801844d31ce1434f2d4e81", + "third-party/nsh/website/elements/data-table.md": "101530f060eac0f9dd0e3d0d5ea043c9565b2cdde26e5bef58e7f70e252b8b3e", + "third-party/nsh/website/elements/date-picker.md": "51026e161c4ffd1a6eae5e802d61d435dcc95632450a949d5c7d88aa5c0d2117", + "third-party/nsh/website/elements/dialog.md": "ebb930d9a56f53e2e1f13361673e21d1a83000f30fdc0989e87274508821e06f", + "third-party/nsh/website/elements/diff-view.md": "94c33bea89117734e9fa6e1764787ba04b997473ea9d0137e341b331637a91d5", + "third-party/nsh/website/elements/dock.md": "3008b9e4b513c87f5db32964a92b834573a0c08270e5f3931d5fe1bd504036c9", + "third-party/nsh/website/elements/editor.md": "69839871bd6a03cfb5c9b057af7db0eb6bec536e52be529d7f8edd7a4920c464", + "third-party/nsh/website/elements/image.md": "df0573015c43b8236277b2ffc03c9e7a38a7183665ce0e0265e73622e53c73cb", + "third-party/nsh/website/elements/index.md": "3fb69181868fc86f20fcfc5e49ee16247d9819e83a1cb05fb54e74c24509efca", + "third-party/nsh/website/elements/input.md": "960fc9729daf27dda6bfdfd4a4d70b70a118f9e404e143c1e28216d0a44fcd0d", + "third-party/nsh/website/elements/keyboard.md": "d083e22be8d06de99c560be44a658f8c04e41f6872e6c2978848ca85ac0948d9", + "third-party/nsh/website/elements/link.md": "d6ea1b7bfc30d78ad69cba3259bc9a000e0b9c5af30a51baaa0da79c4e6fe99a", + "third-party/nsh/website/elements/list.md": "2e7f5a5ac25b79b6552606fa58ad57f572402a3baca1c95d3a3e12e910cfa8f1", + "third-party/nsh/website/elements/menu.md": "31ba2fdc90821007c775d61be53aecb09ab4975493d663e9b185ecb76c3bc37f", + "third-party/nsh/website/elements/nav-stack.md": "e2626e5693f39e2587124e262eed37752fc31cfd2d57b8a394f958b5c117136f", + "third-party/nsh/website/elements/number-input.md": "4dd788dd07f8f04a84730c0e9f50d1a6675034effc9330d782277252c6b4226e", + "third-party/nsh/website/elements/otp-input.md": "4af12f2f611ee7f1620665746c55cfbbcfe7a3c6a43dcb3feae2bdaa25490816", + "third-party/nsh/website/elements/overlay.md": "7e13ccf223d62c09f559078854a859ffc915efca7f57b599058d3eca328b12b6", + "third-party/nsh/website/elements/pagination.md": "e6e2ee064fd489be2af63365720712c0001a75c28755627399b438a6312b1bdd", + "third-party/nsh/website/elements/plot.md": "7c9e472dd2922bf4f9192e204b48eb732891272f5ffe45306ecfc442dae13890", + "third-party/nsh/website/elements/popover.md": "d1e8f951eac29078470667f5d9563c0399b2cd700c52da44c963df3b1173ec51", + "third-party/nsh/website/elements/progress.md": "c9a75dc1aac1177f25192967040b8b9b6666226fc241c909bb57a53bd872e001", + "third-party/nsh/website/elements/radio.md": "339f0e8ab12657a0e2545dc98995ba282f67c21786ea7ee9d0f09605fb5be2c1", + "third-party/nsh/website/elements/resizable.md": "44f050b3e406c21515c8083904a255f6502fc38739f4a1c5960dd5643f855595", + "third-party/nsh/website/elements/rich-editor.md": "7ce7527a5959441734f2413796d87c18f0efb478a448437fc54855b13dfd5085", + "third-party/nsh/website/elements/safe-area.md": "b81d3cc1ab15d9ad0da668aa0b42ec5133e4ae79fc892f53ed3f0931ebd6cbd9", + "third-party/nsh/website/elements/scroll.md": "a0ea652770335f037bce89ff3dd352da2ce51a049975bec13910cfcf86485b5e", + "third-party/nsh/website/elements/scrollbar.md": "961c62cdc9b903d265c7755b5f44860c78a1f1a085c9bf1ef8a1e0adf87da614", + "third-party/nsh/website/elements/select.md": "f12a63526a1fb8ba9cdbba446cf57309d0d89500087a38f7b98d6f0a866b9734", + "third-party/nsh/website/elements/shader.md": "91a78e538a9766d0a224f8bfc2b6d33eb7a312b5fe22ce5a42c87917dfefde14", + "third-party/nsh/website/elements/sheet.md": "9ebaacaa0bebfe35f32f151867acfceb796db73f105342569d3d2c2d30db2723", + "third-party/nsh/website/elements/slider.md": "c3e61f498e530010dbf9bd4fe8a201b8d91d32c056ea11f88d97227dc998b1b3", + "third-party/nsh/website/elements/switch.md": "1be381627cf766cb11787d6eea3aa91de39a4023fe3a5f5a7b2dffb2eacd05fe", + "third-party/nsh/website/elements/table.md": "b35a56c82dadd942f4e98e9f7587f4f2ee960bac913574aeef5f5602f9b939cf", + "third-party/nsh/website/elements/tabs.md": "494b07e0ae81ada42e3ecf691733cd051d115a19fe1c197fd0d8099ca5b0ab76", + "third-party/nsh/website/elements/text-selection.md": "89ae66086f28c087562bf5fb1fc61bee8b5f33c214d7cccbbadb66b07e84df3f", + "third-party/nsh/website/elements/text-view.md": "e2b312f1cbaf01a3701445b7c0711400e18bb407c8e33105c836344a64d7bc82", + "third-party/nsh/website/elements/text.md": "85fcf31a847a72cde0c9d3365cd6124740a23584caaf3ac9b8bbef076e6269e5", + "third-party/nsh/website/elements/time-field.md": "3b1bf397c5cebf4b307a012380a282a31f629ad0cf16f551731488ea5e74a89a", + "third-party/nsh/website/elements/toast.md": "8e86cb66a9413fab493397364554a6d6fd9be823bd7ecbc688cfadf396aafb46", + "third-party/nsh/website/elements/toggle.md": "164e1718617cd98c772e11425f38de827fc2715570f7b6ddc70156c3844d1618", + "third-party/nsh/website/elements/toolbar.md": "6d265b131329a94d9fd70f16943d5282b52ecd23f2b80f4fe40319ed71af8ef1", + "third-party/nsh/website/elements/tooltip.md": "226149fc1288d2e302d76fe1de845ea6baad1c6a1bc09c02854b8252eaebff30", + "third-party/nsh/website/elements/video.md": "7225c650d2f91cceb33e58a921e8d4303a208e71ecd61c8cdeb626d66042c91a", + "third-party/nsh/website/elements/view.md": "7c7874a47a0548fb89e7896d2c264c1618ae897e19d518b8d95ec4b1ddffb525", + "third-party/nsh/website/elements/web-view.md": "fde32e197238ead5e85373856d3e24af32c556959bfa5fcf6e0192df807abcdd", + "third-party/nsh/website/modules/globals.md": "7df2b35ac2fdaabb6e05050905afa5a5334f0510c59bd30f1fcf90edbe5fce3f", "third-party/nsh/website/modules/index.md": "c212e0667eddb58819c8e962bce62f3efff7fbb32d2e3fbd004a68111a302d4b", - "third-party/nsh/website/modules/nsh-base.md": "fef45c8092d93ab9e575b0cb02b23eb150289841439ac410d85fadeeef855aa1", + "third-party/nsh/website/modules/nsh-base.md": "a49d24f30937b983391a4b6deb73a3e6a8023f8c286d8f41257a43dc0c8800e2", "third-party/nsh/website/modules/nsh-canvas.md": "c7a960d552328725ecaba8c3d1a5e6fe1748edfb8ed01248559eaf02ddbaa2e4", - "third-party/nsh/website/modules/nsh-desktop.md": "8b8b3e6b887ef2a74335be9bff10caa72dc937434659f2cccc09cc6000a9e8f5", - "third-party/nsh/website/modules/nsh-image.md": "42e958bf76d3dcb57ff8f1fca5a3fbfdc22acd1f32fb64c94c3399052b9ae14c", + "third-party/nsh/website/modules/nsh-desktop.md": "03650d86c74ad8d758e43ed872808b97dcd84e5c4c4a465f58fe76bcd7c1b369", + "third-party/nsh/website/modules/nsh-image.md": "028257cecdec706c0257bc8cfeb1e31f8acd68b946890f6d621471647bf18c62", "third-party/nsh/website/modules/nsh-list.md": "00682b08aeb8a36e354f7fdda3e2aa05237e3b6f57249071ac2edec8c65c22ff", - "third-party/nsh/website/modules/nsh-mobile.md": "7a91bdeba34c3a4549adf0139aeb6d14ad4ea7eb07c1175750d01b94f48e9fd1", + "third-party/nsh/website/modules/nsh-mobile.md": "13925dacd3b5940fd0e25b3bb888be9524bd764b57e2160946e2080ed81bbcb9", "third-party/nsh/website/modules/nsh-video.md": "08a403c2bf02574eba6e857ecacbcd5c17944bbea9e2268f8bac3a451c4223ee", "third-party/nsh/website/modules/nsh-webview.md": "6ccdea7b98264f703883f6e7d79e49d8e21c49c1979ad7a86e7075f8f4dbf407", - "third-party/nsh/website/modules/nsh.md": "0a9197fbbab75314bb30ccff717b2ba8cddd0ac8130b0efc1fef34ed58cfe7ac", - "third-party/nsh/website/modules/standard-modules.md": "52733b9f77e9d5ea2ab26a685725ebcde766e559f90fe6af8c44800adadb96d4", + "third-party/nsh/website/modules/nsh.md": "a6842fe648687b6aaa0001dfaed88c2fb0baee676c15411f9318f6cf9e6c8b49", + "third-party/nsh/website/modules/standard-modules.md": "d38790f1ea17287e0f8d9e26a6ea67cfbe621b2c3802cbc9b0d00549a3fe52fd", "third-party/nsh/website/modules/style-reference.md": "d8b7d6397dd27e916baa1327197cb970fc7580402260317dba3e68ec20a6fd0f", - "third-party/nsh/website/modules/tailwind-reference.md": "c5e1df18d8dfd65b75e3406970585b39be80400d0c9e067c8fc3ed9af380bfa5", + "third-party/nsh/website/modules/tailwind-reference.md": "15c8d45e271ddee489d55b20afacdc9d9f27abf93ab96d31c70f3d90fe795043", "third-party/nsh/website/package.json": "a367c6e94eb6617e51eea15247f1611b9bc8602e178636ca7269cce004d9c335", "third-party/nsh/website/public/CNAME": "231e9e72291e9315f208db1548681a0204a6f5cd92907609631f79be8e3cca0d", "third-party/nsh/website/public/apple-touch-icon.png": "f022cefb84973be49fc008bf2aa79231314bb6541f7abf5535388c1faf7748bf", @@ -2451,80 +2574,105 @@ "third-party/nsh/website/public/install.sh": "ae90e12b7c84c876b888266d94202c15c8dcb37903a4ba9086888925b8040a1f", "third-party/nsh/website/public/logo.svg": "b5406bd1194c1f739f2126298f2f74c5e51fc9a9a5cf56b734a3852a80d7dc7b", "third-party/nsh/website/scripts/play.js": "e28474ec94685aebf3e2c7cd1781fe8c3ed73fd97d82eae96caa91c6544745fd", + "third-party/nsh/website/src/components/Apps.astro": "3a0bf0fe9f2d185528b9eb2d17d3c715d34f443c85f07b8de9f6e619103238bb", "third-party/nsh/website/src/components/DemoFrame.astro": "5412a67d352efb242a83b1c021a40c9bebf888be77d8ea79a85a46d741e52fba", "third-party/nsh/website/src/components/DocPage.astro": "2e99d9cd0b6117a7297f2f24bd9c4e1d144d7e6d04f4c82a2e14483156fafc89", - "third-party/nsh/website/src/components/DocSearch.astro": "307737b68f753a710ab4df6a3c1ecdd9560973c4b1917ed2dbce218b767669c5", - "third-party/nsh/website/src/components/Gallery.astro": "69c4fceb79a1f264c15274e693c39ec6930c360f141387ab91d05c27935e5bfd", - "third-party/nsh/website/src/components/HeroField.astro": "f0c411892b430d77d3a87df7c9b0743b283c629b1b257687a1d417f3a9571a18", - "third-party/nsh/website/src/components/Home.astro": "d146854be759ffef06f4efbd7b01c6d20ec5adae4e6943e9b0239f4dd2fa656b", + "third-party/nsh/website/src/components/DocSearch.astro": "750e6fe5ad6eaebfe70712335d29008803411478cc59372776e276b1554a693d", + "third-party/nsh/website/src/components/Gallery.astro": "58cd34a61a4024389508ea52699be7b38cfbe29a1fc748ff7c9717901e3f4c3e", + "third-party/nsh/website/src/components/HeroField.astro": "082c180d7429675ab70353b7acc3c4887b174c55351155a8d8f1bd943384bde7", + "third-party/nsh/website/src/components/Home.astro": "6696aba9bc2be2b4d75c16b2dec60918130b306b81c50128a9c9f436df6963bc", "third-party/nsh/website/src/components/Logo.astro": "87e1331cb061ff1fc4fa148151f106c384311921ba310dec610867ba67c8a397", - "third-party/nsh/website/src/components/Nav.astro": "4eee935f9f4020d89955e3e1362697c7aa36ff9951f97f1b984e9b720c78d944", - "third-party/nsh/website/src/components/Playground.astro": "9ff3de1359e423e38507eb32f3f7c55515a65343481213ebb31d52c43dce44f4", + "third-party/nsh/website/src/components/Nav.astro": "3dbf07e5dabc96dda7729fc5b22a5f6fb2a096a6cc64f86d3591c269dac47915", + "third-party/nsh/website/src/components/Playground.astro": "8b123ed6686fb0e06a713dec4ee51e036b5cf84e6988585f39c147aa4c252398", + "third-party/nsh/website/src/components/ShowcaseCard.astro": "2d75002d20e988e553863c64fea6ee79d490a6649ea4d8b4672b1d181b414198", + "third-party/nsh/website/src/components/ShowcaseDetail.astro": "9c0da5b6457cf02430e0c51b523716b49e0ad44f6482f9e5416000241e84acba", "third-party/nsh/website/src/components/Sidebar.astro": "1b9376ee3e62467f6573592041d653d7cb24d352c15ec0ea1fd6a0620cfb6134", - "third-party/nsh/website/src/components/SiteFooter.astro": "fd1a606faebaf869930867ef8845e8be17b633e17dbb02ff7e9ea4f88e72a4fb", - "third-party/nsh/website/src/components/ThemeSwitch.astro": "4577309af53d69de8e039ee1f3c39088cb3f88f62899fbf8234d5c73147d4d80", + "third-party/nsh/website/src/components/SiteFooter.astro": "09d33dcde8c24b1d04cba1a8671eae57b283285114f038cadb49894f60997efd", + "third-party/nsh/website/src/components/ThemeSwitch.astro": "e4ca4ecd581c728218b3a536768f4f73ab3344a55e534d97e79ec894af286947", "third-party/nsh/website/src/components/Toc.astro": "c2a7e68ae12d7def345f326d399d4ccbfe1a17ffb591ebea6d95e7d92c7244e3", + "third-party/nsh/website/src/components/ui/Badge.astro": "4b4d98b4b0d3529bdb22139ba98f246259c7f8a1e1f86813398ccfe0f4198b8b", + "third-party/nsh/website/src/components/ui/Button.astro": "5d3ab9db1696546a9a8e73ce196388bef96bf34979780a012b5ca4110abfa0e9", + "third-party/nsh/website/src/components/ui/ButtonGroup.astro": "ef16abfc34901e366f36dc16aa72f57547817fe6755103e12d02fd3423dfb6ba", + "third-party/nsh/website/src/components/ui/Input.astro": "1e97a82b7f6a3baccadedd8801a1971fe0ba98b7a95f2cddd03eb48936bb8ba3", + "third-party/nsh/website/src/components/ui/Label.astro": "aa2919145a5f677908e06a3527021fee0b104e4a975506d200cfdd150a27beb2", + "third-party/nsh/website/src/components/ui/Select.astro": "c1f35b99c2c2986aa9b01218b08a3edde1afc6f423f7d77e927fe77cd4a07f0e", + "third-party/nsh/website/src/components/ui/TabsList.astro": "f36ef704faa77f12fdb094f5b2bf3b48b402928c10f19356542f6f6522ccb9d7", + "third-party/nsh/website/src/components/ui/TabsTrigger.astro": "8456a65509c2ff25be7f71f96b2e175aedd0d033711ae33fa867ee813e24263c", + "third-party/nsh/website/src/components/ui/Toggle.astro": "de285ce23f74053289d2745ea20bf308ffc33b16804d02296f5412f7c236fc87", + "third-party/nsh/website/src/components/ui/ToggleGroup.astro": "bf1cb5edf37b7f1fa7fd4c6d85b1469db2fa99214a420314633dfe8a8dfd1a65", + "third-party/nsh/website/src/components/ui/ToggleGroupItem.astro": "17e30d9170ff6af2726c6e85e543e5720dec198e53182786e96d81dc522796ab", "third-party/nsh/website/src/content.config.ts": "cb687143731ddd2a2620659cbb36b1eec9355f4d37449bd31ca1d0949f5ac2d2", "third-party/nsh/website/src/dark.theme.json": "f0f26ad7977362478ee03c2722890d89550a0bb03853b85f9b33378ae0766e4b", "third-party/nsh/website/src/layouts/BaseLayout.astro": "474aae0e3cc51b2485d1d3ba77428f22f196ff98adcbea4394c84a5ad5aca9bb", "third-party/nsh/website/src/layouts/DocsLayout.astro": "3ff1b8ad53b9bb389fce2405da0dcb443f4946702c3ce6f446531e5dbe8526f3", - "third-party/nsh/website/src/lib/code-tabs-client.ts": "80746cc8f4cc3ac9df7878a57bef98723378ab6c11300a7fce92dc301d82ba93", - "third-party/nsh/website/src/lib/demo-client.ts": "d389471f98b41bd63f60a63bb4fa31186d4a8a7fbdfc5bc56145926b321e9060", - "third-party/nsh/website/src/lib/demo.js": "f6825d6daa35887f479bc202a88b742beddd38b0add573de4fe5abce4ac1f9f6", + "third-party/nsh/website/src/lib/code-tabs-client.ts": "81ff45defeae515dd3a606a89e31c1e1b05f2cc35c4f064bd2d284b4c598266e", + "third-party/nsh/website/src/lib/demo-client.ts": "ecd1e4b54716d37290e7efeac2d043da06f09a4e9a89af1151e905d0d73ff627", + "third-party/nsh/website/src/lib/demo.js": "c89d3ce224838f1addcd68767117de77c0c2ad26b6efd82839a6cedb02cd2329", "third-party/nsh/website/src/lib/gallery-ui-css.js": "e1f19c17d17afd257f970cfb8b8cde8f637ebe7b5de8a92f49d8eb839c71b3c3", "third-party/nsh/website/src/lib/inline-gallery-ui.ts": "2eecba0f193291ea4922fd91de1b06c044b5bd0f549e16f988c46d2048c137c1", - "third-party/nsh/website/src/lib/llms.ts": "eb0fc162e93316eacb86a97ad61755e5276d94be32eb1fbf2999835cbd945085", + "third-party/nsh/website/src/lib/llms.ts": "773cd3de4206cece7ebae73121c1dc8c4b24def670a4d9654c61f4aafba34cbe", "third-party/nsh/website/src/lib/markdown-endpoint.ts": "1d551af7145b0c1148b4ead6d13d772115e64504e5bb5ac070d2890cdc796488", "third-party/nsh/website/src/lib/markdown.js": "1da143867f85b361dc8b46753a8bc98062055b48ab4ef550b2036be4989f0f50", - "third-party/nsh/website/src/lib/play-dev.js": "da48beaf4d831d2f3ab43f44bb1fe6ecf380c87cbd907fdcb8a733bb0f46a9dc", - "third-party/nsh/website/src/lib/playground-client.ts": "ba0e39f22f87f5f4e051abfe8819177b4cab5552b3969fefd6837734cbfba27d", + "third-party/nsh/website/src/lib/play-dev.js": "a7a6f6fe7e485cee3e516debe17c490e90b6e1a0151f7ce998455aa925158776", + "third-party/nsh/website/src/lib/playground-client.ts": "e6e5b8271371ba062e28918ee76c49d18cc7f96662e77af552399d960100e3c1", "third-party/nsh/website/src/lib/rehype-heading-anchors.js": "0c322cfdb0ff1b56f9757829650c82eec089a9ed846a909cf733cb043d5e3ef3", "third-party/nsh/website/src/lib/remark-callouts.js": "8722f4720a9939e5800ab41403b3faefc7462c690886d25648ea707cdee8c67a", - "third-party/nsh/website/src/lib/remark-code-tabs.js": "e53fe3fc8d242cf893c027c3dc46e28108a272923bd10af4d217fc656e1059d0", + "third-party/nsh/website/src/lib/remark-code-tabs.js": "f94fdf53545ca3bb900e98dbd4aceb0cacf66094ebb1b0cda9eb7babdd93c2ce", "third-party/nsh/website/src/lib/remark-demo.js": "5bc155aa83204f7822883aa62126204ea423cb4674f7da5b8a015ff366938d96", "third-party/nsh/website/src/lib/remark-doc-links.js": "4d1305578398c7171eab60b413d4593d2c0682aec39544c2ad2969b847dbe160", "third-party/nsh/website/src/lib/remark-strip-title.js": "3068a83d3a0cbc8e5acd00e40cb658b5ae130dfa74f6c7b5dcef7e90a76c7038", - "third-party/nsh/website/src/lib/sections.ts": "7ded7d3eb590f56e62e47595823fa9ca7677fadc8691af5f95abc01b163ec7d2", - "third-party/nsh/website/src/lib/site.ts": "bb5d441396687dec1c095f083295d41cbc77fbe84762faa3b4370aeb978e7c49", + "third-party/nsh/website/src/lib/sections.ts": "acb79b9fb0477e9382ea740188c90cda29dced0c7a8370400ada23c658871be1", + "third-party/nsh/website/src/lib/showcase-browser.ts": "19654db144d75f3c8578690a9436bf004a25abb6923d6a4f7d63662219024436", + "third-party/nsh/website/src/lib/showcases.ts": "691debab5b3585aad3adb25bb8e41d098e7f50c8eb7f4944c000af1388a40f10", + "third-party/nsh/website/src/lib/site.ts": "2497d033e8b730141e445f90953047dc661b9c667260e4adfb10299dc137cf94", + "third-party/nsh/website/src/lib/ui/select.ts": "e6f3005c48cf86df2f4c5fa31457095f6e289b94757c41b2c3df94e2d9b42b77", "third-party/nsh/website/src/light.theme.json": "6f17f1644d1037c2028526d36af10093590d9a87d02e417366a75fb93754461b", - "third-party/nsh/website/src/pages/404.astro": "2931f5edd7a4697a17e7039d75373d26ebaa6a9029f7d8419ae6861f969f4a29", + "third-party/nsh/website/src/pages/404.astro": "979da3024712e8765b2a1d5eb413e83fc6d7322e649384a6fac2d71ae30c57e3", "third-party/nsh/website/src/pages/[section].md.ts": "06ce163636180ef2c277f041a7b5042558fe8551e73ed98dd53735791ec3e296", "third-party/nsh/website/src/pages/[section]/[...slug].astro": "abb1589376d34d85f57f948130fd0950c74da1e0bb83a7337824eba9f2ce16f8", "third-party/nsh/website/src/pages/[section]/[...slug].md.ts": "9c2e72504e51527ddb0cd90b3b59252222f9d98c6c4ff5eed88cd41023a24a38", + "third-party/nsh/website/src/pages/apps.astro": "fafbe2ae5d6578aaf2fa5d68f35b4b306df896be0c53d6fc8d8df78f3fcb5a09", + "third-party/nsh/website/src/pages/apps/[id].astro": "ae0b40ffde2fbc03e05dbed464a75eeb6de79aa13fffe138c6eb1df105c9a6e7", + "third-party/nsh/website/src/pages/apps/[id]/[file].ts": "bfd0ca0b677d3593fd2775fca5c24f6fe5168477de74c4ea979924fc57e25d80", "third-party/nsh/website/src/pages/gallery.astro": "61ed4203558357d96859bf9842537e7b5daaab308f9ad735e21ec92507732140", "third-party/nsh/website/src/pages/index.astro": "21e8ff5494906fc0ef1c8e94d546c9bb875058a12369b24f23a06fba7efea6c4", "third-party/nsh/website/src/pages/llms-full.txt.ts": "c27d063b5afbe2649c282023cac102c31a2bc091e70ea0be8713de00ec9e20d5", "third-party/nsh/website/src/pages/llms.txt.ts": "008654c7fbd89b525a127c3f0c89d435fe6cbaa31232216593afb51e89f31174", "third-party/nsh/website/src/pages/playground.astro": "219233d1e91a9a2adb15cf0701725b595ce18af8523d9c207a21e0c4aab86a1f", "third-party/nsh/website/src/pages/robots.txt.ts": "9985af4005121448362df9a41aa1f4fc507080d59d93438f5acd6f61f914cc93", - "third-party/nsh/website/src/pages/sitemap.xml.ts": "4fa6c2b101659a25b0f0516fa28c3f0446dae293756bf7f504e0bd8fc0eff5f1", + "third-party/nsh/website/src/pages/sitemap.xml.ts": "8e263d0784e404234b0168773edbaa11762572546995aac868c0a2718ec1b3c8", "third-party/nsh/website/src/pages/zh-CN/[section].md.ts": "66663cfc51cd615e873b0cdec857bcd331f517bc9dda983d73b33bda2d8f53d4", "third-party/nsh/website/src/pages/zh-CN/[section]/[...slug].astro": "ee09974de95eaf625c2354a550dece0728090680e08cd0a26ee5efd754d041bd", "third-party/nsh/website/src/pages/zh-CN/[section]/[...slug].md.ts": "f6e8aaa3c2d33d5f8311296291786e8bf79e8802ad858984dac4034d81c9f07b", + "third-party/nsh/website/src/pages/zh-CN/apps.astro": "5e7cb3a1b6a9f0b27c4a34084bdb7d4dbd9c0b4e07cf4c35a6732624df3205df", + "third-party/nsh/website/src/pages/zh-CN/apps/[id].astro": "1c4baf6ace194b57517381207ca6e48937e167b2125dfbcdb1a1cd62dae7fa65", "third-party/nsh/website/src/pages/zh-CN/gallery.astro": "1452289ad1d3e8792097890d3e40de472a0b534e241a615d081070618a92635a", "third-party/nsh/website/src/pages/zh-CN/index.astro": "fe827612ee6c77431f2a4bc373b82f6894a5b9da1f2ffca122e1af2cab229f34", "third-party/nsh/website/src/pages/zh-CN/playground.astro": "e7c84a4744dfe5359699e9834680743f973cf7a2da38dd7780fcf9e92e6233d8", - "third-party/nsh/website/src/styles/global.css": "a97e987dee4a2e0de8931608751be8e8521fc1958d7b1a31c142f44e856cde67", + "third-party/nsh/website/src/styles/global.css": "2cc04cb4f65a97a6ed860f6e4ff2bf190785df5d769aeac748329920bec215f1", + "third-party/nsh/website/src/styles/ui.css": "700938d03f0eaf768b0494af2fc2a6b26edc14c257e8e703647f2d90d52b0fd1", "third-party/nsh/website/tests/code-tabs.test.ts": "3918f478bdf0c95814cfdce4358649ab7a14cc2acfe17aaeb045009c7d431613", "third-party/nsh/website/tests/content.test.ts": "8245cadc068dc42cc6a10cdbaf100e0801b21d4074d8b4fcd94d9144d5e7d37c", + "third-party/nsh/website/tests/elements.test.ts": "f7e9e1f5066eb6a119b67b15fce4e99e4a9617efcbea182a9c98ce2f0bb0cf6a", "third-party/nsh/website/tests/gallery-ui.test.ts": "d6406f10f90ba3c86f1fdf9bc5c17676e0636fd385f9bf63b32f9d20e0a300ea", "third-party/nsh/website/tests/links.test.ts": "f6412e9693e69b809f694c8600ec8ea5e06315b84a84410215e57003c661d47e", "third-party/nsh/website/tests/scroll-snapping.test.ts": "85297df76fa6807ceead15a7bcabc2a456c1e8739657698d4a4d3b9dfc3abdd2", - "third-party/nsh/website/tests/storage-keys.test.ts": "ac813dee8f2aa6a4db11dde2c7d677e8aad46be7b63d209457e0511095673459", - "third-party/nsh/website/tests/storage.test.ts": "d7cf792244d3e24ec50e10ed17ce150de51f14932e482029ba2b21384ceae36d", + "third-party/nsh/website/tests/showcases.test.ts": "5ed3991f26f60e560665d07a2f9089e1a8fb09ffacdfc7b56e72cd0e27351535", + "third-party/nsh/website/tests/storage-keys.test.ts": "1b8ea1cf5234835d3d2ee8db9a805f73e4e2f5d97fbc4b2a3bab9e0e7cf2fc3d", + "third-party/nsh/website/tests/storage.test.ts": "1eb2a66d6a2874baa99a288d76eacfd8a8eeb2c146fbfbb60b98c753682e0406", "third-party/nsh/website/tsconfig.json": "7b292fd267352f901284513544cd818de4a8a8fe15880f662b3a0900b2a93c99", - "third-party/nsh/website/zh-CN/docs/accessibility.md": "1010722c49889e9b6ed724fd0504f46939c2cf313d50dcdfc17a8d2fe7c562d9", - "third-party/nsh/website/zh-CN/docs/android.md": "43f0e73a5f9ce7f44456312d2b12292c6807af33e9917de41becfc99f6533609", - "third-party/nsh/website/zh-CN/docs/animation.md": "e167a626bc4d2376fcc0ed10fd47b52189292892f4052c14926941f3166a8c62", + "third-party/nsh/website/zh-CN/docs/accessibility.md": "ed2f5c4ba6b8f2e791ee972fbfcb0c0c2615ec58bc3672c4b14b58fc42a842a4", + "third-party/nsh/website/zh-CN/docs/android.md": "543af7bc985e67ebb6f2747b1dd195ece83e73616f6c55d81681c7c13ba7e48f", + "third-party/nsh/website/zh-CN/docs/animation.md": "a8ff3c544a6a52974de49fe1b4c1d987c20a3c11d27c4bcd5a2cde658c482e1e", "third-party/nsh/website/zh-CN/docs/architecture.md": "30c3fb780f7bd9750b095b9a851e2b201e859e5c198d24cfa301542f76f1cfad", "third-party/nsh/website/zh-CN/docs/canvas.md": "0ee995570d9a11a12c492d8f210478cb3b12d4d91f4b7b9e2368eb46d2d83bbd", - "third-party/nsh/website/zh-CN/docs/cli.md": "b6179f4c5b5af445f710fc1be6816f899ff14e229003ab65e15314ce07236672", - "third-party/nsh/website/zh-CN/docs/coding-guides.md": "e96d38846fbed88f2b8abfdfbaba2973fc783c3de125f30f7c672ec891f83208", + "third-party/nsh/website/zh-CN/docs/cli.md": "8a36afa2fda32fe6d32970a94be60c93a173f2fafeab6ad77ad613859e56b420", + "third-party/nsh/website/zh-CN/docs/coding-guides.md": "cac1f2848b3fbfa5ad05c84b2154a0579e393ba20d497694923fc854f798efb2", "third-party/nsh/website/zh-CN/docs/comparison.md": "05e6ab8a44d3db224895d7029e029dbb48a9e74092e5135099ab0179a6ee974c", - "third-party/nsh/website/zh-CN/docs/compatibility.md": "1cfb20082e1886f883884e0f7212cddd4ad17e4636f07472e55348ef3d95bffa", + "third-party/nsh/website/zh-CN/docs/compatibility.md": "4857cd153b77a731dfb1efa0ea51edfd56294eadfb489c8e6e6f64d0133acfa5", "third-party/nsh/website/zh-CN/docs/design-guides.md": "82626a019f4eec087378090412d9e805444c4cfbc1da35500dd3ae937e4dba49", "third-party/nsh/website/zh-CN/docs/dev-server-and-debugging.md": "473e2940da40bc0d03556c49d98112529040f55631e84373d1b9d92166248f2b", - "third-party/nsh/website/zh-CN/docs/device-and-environment.md": "8a4c1d9b40577fa3fe2ccae42232907d6682eaaec8dbad51a1ec68f0b84038f6", + "third-party/nsh/website/zh-CN/docs/device-and-environment.md": "e07345cca2026333231db0c836c1f44a198ff1bd5efaf9253dd2b40337305734", "third-party/nsh/website/zh-CN/docs/fonts.md": "bafde228d46ef19033c251ac79f9915163e3fe21560c15d5e6487bf3b38b095d", "third-party/nsh/website/zh-CN/docs/forms-and-keyboard.md": "40dddd40bc1bd5c180f6cfe0c520b687a277e9deb1ff26c8d3ea4de64910cb3f", "third-party/nsh/website/zh-CN/docs/getting-started.md": "e018ca0600c3e5931255ebae1321eea5ef106b33c05ab6b589a87266234a171a", @@ -2541,88 +2689,98 @@ "third-party/nsh/website/zh-CN/docs/lists.md": "0e88d458f3fc63d0a1337246b22b1bd3788bbebfb87a1aefa0fa7a8018f4b43f", "third-party/nsh/website/zh-CN/docs/macos.md": "817e09021816008270f0d19cb8f54f91258b795561a6169ddc96b89e8a2159b0", "third-party/nsh/website/zh-CN/docs/native-components.md": "fb7ed32dda5cc420acd0dc9470057480a226d8e777cdb63508d971d4abc578f6", - "third-party/nsh/website/zh-CN/docs/navigation.md": "34bd2faf07b843d25bf04c583c8cac8e3258cd912d04cfca58eae208eb376769", - "third-party/nsh/website/zh-CN/docs/networking.md": "33e550f9bd00f6b6bfe0a73790f1692ec656e08deafc1d8200473408117aa232", - "third-party/nsh/website/zh-CN/docs/packaging.md": "fc0472815af929204cd73817062cb4aa341b6b321e86650c5ca09a657c6090db", + "third-party/nsh/website/zh-CN/docs/navigation.md": "cc82bf4eec4e53b551ded9a05552608cb099fc06cf7767c30ad0602cc8012608", + "third-party/nsh/website/zh-CN/docs/networking.md": "c0ddc6a1a8825020844c9833af5ec917c8c7895e9197c7eee0bda1880f74ef3e", + "third-party/nsh/website/zh-CN/docs/packaging.md": "395418a0b3c7e6f62c0d35468aecda2287cde5635e1ea81227db5fcce95bd343", "third-party/nsh/website/zh-CN/docs/performance-report.md": "aa4ce2bdcd22d29ca539878a06aa64618d47af76b3c17d437c3e2c4ea2a281f7", "third-party/nsh/website/zh-CN/docs/performance.md": "50c744d82a6f9174f68683c0b6692e11457d7c94aa1edc10b536af3cb8a7f421", - "third-party/nsh/website/zh-CN/docs/platform-specific.md": "362ce15a3ecafa0637e272872230bac58cdde0283b2323aaa6dee36cc9b4c479", - "third-party/nsh/website/zh-CN/docs/platforms.md": "b07e2b79ebcb7c155745b274d5ff7796e5291d18e362be52d67e4bfaab042a5e", + "third-party/nsh/website/zh-CN/docs/platform-specific.md": "ccb0d0eb239985a349e2fbabb3dc2da5aa22f76c1c396541141667c8dda41464", + "third-party/nsh/website/zh-CN/docs/platforms.md": "b4c023192096fe8f8c1732706862d19d2a4680c4f28daac464f27f9c797a3a55", "third-party/nsh/website/zh-CN/docs/project-structure.md": "7ba3d12313e12575a72fc75919a42279801ab83514162ff52e54bc680d4b2e71", - "third-party/nsh/website/zh-CN/docs/reactivity.md": "bec1b7c000fc09327bcae6d4995063e789de39158bc53e4e0a7034c6a45d60f8", - "third-party/nsh/website/zh-CN/docs/rust.md": "84ad97301a616c75ae516e4887c10e8e29303a3d0390bf2b01b2f6688670ec01", - "third-party/nsh/website/zh-CN/docs/sandbox-and-capabilities.md": "ad970074dbd81534e70ef557487fae1d8a11aeb0279db06ab90741c37d785b06", + "third-party/nsh/website/zh-CN/docs/reactivity.md": "81393e6b9e8f858b80423f55d99934065089c5b31bdcb4f2a52ca9de86bcb293", + "third-party/nsh/website/zh-CN/docs/rust.md": "57668d4d70dabfa33c9a1ac90157c440fa36a5648b63593d8e52ac217db4a329", + "third-party/nsh/website/zh-CN/docs/sandbox-and-capabilities.md": "1f7569af01059f2ee69ac788b7ea1f66003104c2e67f0119c9cc52da23fd6b22", "third-party/nsh/website/zh-CN/docs/scrolling.md": "e43de6cc8a71b75e6b1841e0f442a08453feaf4549bd342083f1b70b50aebc24", "third-party/nsh/website/zh-CN/docs/signed-packages.md": "2e48499114cf8cfee2cfbfb866b46afa8b5dc9c615f2640462295e0fbaf0b47d", - "third-party/nsh/website/zh-CN/docs/styling.md": "79b497bf4bb3f9ea3a4b97a8e4bfb52881dcb2d9ddeea140fa5c31f4b14c4a03", - "third-party/nsh/website/zh-CN/docs/tailwind.md": "84b061d3dc565b14ae4fc7a1657e23c8ca6c777ad068b0c0e2f2d57a35157c38", - "third-party/nsh/website/zh-CN/docs/testing-and-trace.md": "92652a88a729e862429f7d446d5ca63d48886e1462db269cbe7443740e3a4cb5", + "third-party/nsh/website/zh-CN/docs/styling.md": "a238d81328eec77835de146e4ced1ffe2d72bbcc324f892d608191eb7b985c37", + "third-party/nsh/website/zh-CN/docs/tailwind.md": "8e9e5c2d28d3036d92cc46209a73c613a3cb85e5bc3a1f9bf3fc246acd973bcc", + "third-party/nsh/website/zh-CN/docs/testing-and-trace.md": "62e82b20fd62798c5c6379500738cc317ed32f012985442d3996227564066992", "third-party/nsh/website/zh-CN/docs/text.md": "1ec54d95cf082a8d07cc414626bb985dcb7d5f08d77b43dce6789fd7b6aff5f2", "third-party/nsh/website/zh-CN/docs/touch-and-gestures.md": "d34c5fa64d01f67001e740870a2130a20073cfce448ba962999a5320762de71b", "third-party/nsh/website/zh-CN/docs/tui.md": "678e9ff2017ec57ede3d8e6ec1b92e3709f37cfa8f5d5055860bf94be8286881", "third-party/nsh/website/zh-CN/docs/web-view-and-video.md": "1edf0851696ac1937e23dd61b827ead0a5451ade3fa23d052dae11365081bad9", - "third-party/nsh/website/zh-CN/docs/web.md": "5b356e2a049c42724245d333e0548487b077f1d59f830d0c206ed4a0d9060a23", + "third-party/nsh/website/zh-CN/docs/web.md": "b3751e8e8d5e4631f3d3ae8a0d4c215896035c9a696c812e8b87c92878f1a19a", "third-party/nsh/website/zh-CN/docs/windows.md": "5db11b4e6a2ca954d2a06943796813a308cfba4b212d6647390c1a71fc73eaec", - "third-party/nsh/website/zh-CN/elements/alert-dialog.md": "5094df8bda343ffa89246e8ad7cc19571e9b4cfbc15444abfd12e91c9ca1f77f", - "third-party/nsh/website/zh-CN/elements/button.md": "0de03bcc8e01792961a26dc4f506c0d27e2984635ca86b469cc19cb21f966da1", - "third-party/nsh/website/zh-CN/elements/canvas.md": "e7260f2c740d134b215a6acf429c1e6d22eb862bc2e0332e48a9d996f6a2ba0a", - "third-party/nsh/website/zh-CN/elements/checkbox.md": "c387b79ee0e9d5f50c2552e11afe79b0229f2e106d29adb1e460e2f481e806ff", - "third-party/nsh/website/zh-CN/elements/collapsible.md": "09511209802ca457b63d8b0bb028b93215368fb5e738d0cd273a831f91c5f9a2", - "third-party/nsh/website/zh-CN/elements/combobox.md": "29b3009f6737e103b60cba1e04ed4e54e2288c422ce73f2d6640c6762de3b78b", - "third-party/nsh/website/zh-CN/elements/control-flow.md": "e6560c622e162753ede4b5a7f39b30a4322f25e66fe98c4b3c3699d3f53a829e", - "third-party/nsh/website/zh-CN/elements/data-table.md": "3e9950d93780030a13759577e0a4827010709ea8c921947e0a9dc4617821b1d3", - "third-party/nsh/website/zh-CN/elements/dialog.md": "8764795e78fda820f97512863aaa6b60e7a01ac53383027d7e434b3ce29aaa4a", - "third-party/nsh/website/zh-CN/elements/diff-view.md": "31fb0f7542b2a806f2dc26ad218334832d8d11a0490c24e5407696121d982ab4", - "third-party/nsh/website/zh-CN/elements/dock.md": "7ca9922607a2ea4e7c93c6e5aadd548ba97135aa6af9b6a8a5c6e5a86ff8aa52", - "third-party/nsh/website/zh-CN/elements/editor.md": "34321987a3a43e809471d79acc6778cb201e8dda8b7f0965de217130562f86f9", - "third-party/nsh/website/zh-CN/elements/image.md": "fbc2b5b671318bf8345144bb654ac89ed1e195a1e939b3bd036a2081455db427", - "third-party/nsh/website/zh-CN/elements/index.md": "b096bc77ca2070489d282311b9a44deea68ccb8c5d8e8f7e3aca147ec604602f", - "third-party/nsh/website/zh-CN/elements/input.md": "29641bc37b9ab19ea049a3d65ee62aec77c19b3de67916a1a4b5a35a595640dc", - "third-party/nsh/website/zh-CN/elements/keyboard.md": "689869f8593059113bec67ed9953bed6b45072bed3f6c9d2df6fd7d10a3d18ae", - "third-party/nsh/website/zh-CN/elements/list.md": "a5b05ec5dc5c5a4319ae8f3147231ca655fba62dde9faff4d79bfbc85431f789", - "third-party/nsh/website/zh-CN/elements/menu.md": "d35990619e277ca84a9f6e2ad4adf659bfb789855ccfe6f69f4d34e6a724cfbe", - "third-party/nsh/website/zh-CN/elements/nav-stack.md": "f8b31c138c46b4863d332ee5f23a5c15bc4c54b3c49e6635ea6d1e04d5266156", - "third-party/nsh/website/zh-CN/elements/number-input.md": "33a6332fc86d0a9c5e41ceff70b36aab75902ed4dee2c117b9249ef80decebdc", - "third-party/nsh/website/zh-CN/elements/otp-input.md": "57c7344cdcc2a05d63f737daa408d6ab8424ab472b1babf5e4cdc9e6a0729f91", - "third-party/nsh/website/zh-CN/elements/overlay.md": "667ec88a556f8c28da3d2698c496a53a8bff408942b20bdc34b27c91250f5abe", - "third-party/nsh/website/zh-CN/elements/plot.md": "dc26226fe91f2384a8a8d8cbeb22c7e8ef2ebbf44f99fb90a71e659838db1d98", - "third-party/nsh/website/zh-CN/elements/popover.md": "4a14a7f16f0722d797eec038cce0f2f9201d2d2151e3dcd6bd2de4dc906510f4", - "third-party/nsh/website/zh-CN/elements/progress.md": "22b62b3f9b0822b3a614f4fbfef8f8f0270625f56abcdd1a679f157ae207858f", - "third-party/nsh/website/zh-CN/elements/radio.md": "1aa178cb3d5631a1d47e6721c299c9eb7fb3d0a3b05bb2c518a624c79b3a5b4d", - "third-party/nsh/website/zh-CN/elements/resizable.md": "3e67f8f3502e709adece7c694695ef94808b56f9704e849f4ea5af8c99e3442a", - "third-party/nsh/website/zh-CN/elements/rich-editor.md": "77ad307ac8523a0b3856d5cc76de2126ac8dbd3f29b5b7cc2a86bb30cb5a1cf6", - "third-party/nsh/website/zh-CN/elements/safe-area.md": "6920a3c9408d485096f2a4e86cba266c2b93957681af1d210af3c5f0b0dd311a", - "third-party/nsh/website/zh-CN/elements/scroll.md": "2c1d9d7a8de4f23fe6cfbdf5fa43aec42f336dfc959785ef9821a02e44c65b72", - "third-party/nsh/website/zh-CN/elements/scrollbar.md": "7aaca45663a03d2981e680775cf88956624e263c049457572e9ad5f54b07bc8f", - "third-party/nsh/website/zh-CN/elements/select.md": "8415a5bfe018f64df02b11ea3c93c605597920769f56c2fb48fb33c4917c52ac", - "third-party/nsh/website/zh-CN/elements/shader.md": "9c27a3c96c7c58c5500cc2b947525d52f98242728344f789abaf1360dc10bb34", - "third-party/nsh/website/zh-CN/elements/sheet.md": "964b530dfb80bfa92bdb712f1002e42ce42ca663aa8b8e5b532d8e39db888a5a", - "third-party/nsh/website/zh-CN/elements/slider.md": "d9ceb24253d9aad98a114651a7b4ffc900cbdfc8453f7c4c9a012a7943e9a70f", - "third-party/nsh/website/zh-CN/elements/switch.md": "91cbe3c396032cd4d540c8b24eba0eb656f38f6d1ce3c878562b438d29f57a56", - "third-party/nsh/website/zh-CN/elements/table.md": "9c735e36f97ad8ccaea293a3611c6bd8b8d9de35cb4a2cbeec357532b1a19fcd", - "third-party/nsh/website/zh-CN/elements/tabs.md": "4713f889edaf6588f70afe6ebbc2d132c1c766d39ffda798ba6e2e1c897e799d", - "third-party/nsh/website/zh-CN/elements/text-selection.md": "c5187bf51c8bb358998f17b92ca3331ba9c8a2d767660f8134b0413dc2d2a9a9", - "third-party/nsh/website/zh-CN/elements/text-view.md": "e345ce999914388b9f21890f55ab4e662632866108594b1dd423128b109460b3", - "third-party/nsh/website/zh-CN/elements/text.md": "84cd740a34d76ab2a9f20b2d51fb651ff385bcdc3f7577e3a5b5a2a1d01b5f44", - "third-party/nsh/website/zh-CN/elements/toast.md": "70ec8d2ade0ece446c774ad39faad52aa7636cef6540c26519117bd3de84abd3", - "third-party/nsh/website/zh-CN/elements/tooltip.md": "5193171aa82aa39736ed451453599d2070b58728b2636826d58d014b4ddf50c6", - "third-party/nsh/website/zh-CN/elements/video.md": "9862a8179d7a1821e27b35f9349eca6218b8e9e32e29f942de13df29c0cd4a8a", - "third-party/nsh/website/zh-CN/elements/view.md": "38a81b2ec01cc708a750adff3abf6b7273592fd06fcf94768ebb32d450196d88", - "third-party/nsh/website/zh-CN/elements/web-view.md": "34a70c577e5b65bb01c7b3cb6d87f4009425824a76292a39e9da3cdf987cc74d", - "third-party/nsh/website/zh-CN/modules/globals.md": "fcad6cd6931b94c6c8bb28ce8fca27ad235c801f773977ee10b036f794a0995f", + "third-party/nsh/website/zh-CN/elements/accordion.md": "609b186d22ee0da7aec18e795c8a03b5fdb434001177e995e43b2c5756c1bcbb", + "third-party/nsh/website/zh-CN/elements/alert-dialog.md": "6d900a2b3e8bd79e58eda2af0fc7940fe965ccd484eeebdefda0008e2ec8540a", + "third-party/nsh/website/zh-CN/elements/avatar.md": "e7f332adb46af178705f715fca8fcdf96ffece10b1a2f8a63a314103d5e0d033", + "third-party/nsh/website/zh-CN/elements/button.md": "35ea52dc2feff46d0973356660b6bcab2a2da5eb0d2c070ef355e95d76ede121", + "third-party/nsh/website/zh-CN/elements/calendar.md": "61a762fa371527655bd30209db13772983e9ed523303dc7b2119e8489fe86060", + "third-party/nsh/website/zh-CN/elements/canvas.md": "817be297900d143375f2c7832fc5ab7db5c70ca6061b23b8aa67f367b412a632", + "third-party/nsh/website/zh-CN/elements/checkbox.md": "f44024f4ca909e5012283c357698b38b27e4e04b0f663f87d8326136a1b3e5ef", + "third-party/nsh/website/zh-CN/elements/collapsible.md": "78ab3a6d3226e38f32b2c09180cef6147fd0e0ff16ff6204fb7e8694ca877afa", + "third-party/nsh/website/zh-CN/elements/color-picker.md": "0d5a5c7692f75317d7c7a47071102ddaa8a15bba0f94a799d3b8317655714105", + "third-party/nsh/website/zh-CN/elements/combobox.md": "8357b993153c27311b5a438068d07a3a84f4c777098d82c05427298c1b5a62ed", + "third-party/nsh/website/zh-CN/elements/control-flow.md": "dcdc028ab047f6ce026e84dc21dce55bdcc7eb46735ce50dd795603becfda65d", + "third-party/nsh/website/zh-CN/elements/data-table.md": "808d5d14b2bad0c71368fa02e70a0342248ed5f7f1b5efe596efdc6baf281d1e", + "third-party/nsh/website/zh-CN/elements/date-picker.md": "b2aab1ee781c5827077993486fa980946758ad54ecfa37936c074499ba4d197e", + "third-party/nsh/website/zh-CN/elements/dialog.md": "b4484e838b67fe15e6f2f90d1531ed5b58be006b4216ba3c752596e6e2cb5702", + "third-party/nsh/website/zh-CN/elements/diff-view.md": "2cae739b62ebde55945b566bfff2e216389a7e90655ddc95e6344aa30c09ff81", + "third-party/nsh/website/zh-CN/elements/dock.md": "c9356054f627c827d695a48ab3d063010acb91ec4f1f60be2adceb92c0e697fd", + "third-party/nsh/website/zh-CN/elements/editor.md": "391ba9313a9788df14b96a9f1829e108a431d91d475a0382cbdf634150aca5a9", + "third-party/nsh/website/zh-CN/elements/image.md": "52341d75e4acca4044f17c107ec3a6553f60a1aeb9428250c21c991a25a07b2c", + "third-party/nsh/website/zh-CN/elements/index.md": "9a3439e8d6584d25fb1325e53ecc1f21103440268af6d87285dc23406eaf5096", + "third-party/nsh/website/zh-CN/elements/input.md": "20f93b468184297fa02bed8a35a590cb2f627bc77184d862c6e95556683f78ed", + "third-party/nsh/website/zh-CN/elements/keyboard.md": "a4fed676677640d88694e30aaa5e0e4921611b56e58de4601709aa03a9fe1d02", + "third-party/nsh/website/zh-CN/elements/link.md": "93e07f75d1460188e7cbe679d312ce1d99df0240fd63a05cf44c3670e524317d", + "third-party/nsh/website/zh-CN/elements/list.md": "2ae3a31d416f410470ccaa02c8190f778c1851f88feb2602224e0584a03c3d22", + "third-party/nsh/website/zh-CN/elements/menu.md": "015394cd57621fd4a82e39df2c41c56f825d2f37d32ecbfb327e8a249dff377c", + "third-party/nsh/website/zh-CN/elements/nav-stack.md": "944d2510c66834fcf2d735c8dc44917f3ab8c9e0128d81ac479c393b3f7d26e0", + "third-party/nsh/website/zh-CN/elements/number-input.md": "f526cdf1dc7fcaf867e6a7be7c8d0082d52bb3b2507a335177e2abbbc43f9f37", + "third-party/nsh/website/zh-CN/elements/otp-input.md": "d4309caa37929e87735452b7e3d86896ca2ed6a7c41f6dd072ab5538f64a3ac1", + "third-party/nsh/website/zh-CN/elements/overlay.md": "8ad7dda8fd3bbb580c180e47c2dc34c70f9722f32ae55a2f7afd9d1c60c0e43c", + "third-party/nsh/website/zh-CN/elements/pagination.md": "7116a90ae3eeb89d84bf1c5297d58aa121b9675fca7a947058a7f78488a913e2", + "third-party/nsh/website/zh-CN/elements/plot.md": "dac1fab6f748a0251f99cf6c1878171945fbd99c79eddf953bac3ef65402918b", + "third-party/nsh/website/zh-CN/elements/popover.md": "89f4e440ce37ef4cecb3bd499e59bc2671047a14769df6cde2a50d57a0562865", + "third-party/nsh/website/zh-CN/elements/progress.md": "9bf02f01138f42aeee2f1cfa1c02d085ab0d1a624b46cf3fd14c2662eec31b6b", + "third-party/nsh/website/zh-CN/elements/radio.md": "88fcdc116d4427e91bd7a9fa3390e5ea7feff9b7fcb9bdd3cccc8014cbfa844a", + "third-party/nsh/website/zh-CN/elements/resizable.md": "d201ccea3649e0e168d7fb04d8466fa94f6bdef01a10f5150fdd429c29a6212f", + "third-party/nsh/website/zh-CN/elements/rich-editor.md": "fe1605c739e5576c4aab5dae4e2fc3585e569c41209be222e7914329ad77b533", + "third-party/nsh/website/zh-CN/elements/safe-area.md": "970eb11fc62e52db185a0eab539860e9a697225474ab22d54a68e3f781f161da", + "third-party/nsh/website/zh-CN/elements/scroll.md": "aedd5169ba7492d27cbc3d94f5ee73c3859b3fce827c30609e27a2bf5127f951", + "third-party/nsh/website/zh-CN/elements/scrollbar.md": "1b70e8d92bc980e71860c2ada0f0a8aa00cc3081c55e5f73e2f5ea4cfc8d5d12", + "third-party/nsh/website/zh-CN/elements/select.md": "25a4fc2e616b8057f2c98c3856a21638cd47e4d1622febec01934f3169074e84", + "third-party/nsh/website/zh-CN/elements/shader.md": "208b5147db8d180bd28715d9fbc20695295a099723408c90160bbb5c7837a0db", + "third-party/nsh/website/zh-CN/elements/sheet.md": "05d76555f08e7414ff79bda4df0b7642c19401598e8f9a418a7d9d1b20d9e297", + "third-party/nsh/website/zh-CN/elements/slider.md": "5f4f1505c3bb60167e7f8039efcd9c597a727ff128d5af2f67e358169825d56a", + "third-party/nsh/website/zh-CN/elements/switch.md": "be433df2ad07c7ede76a71b4282e479fcacbd3964a37b612bff5fa848698c97f", + "third-party/nsh/website/zh-CN/elements/table.md": "6f5f627ded8b2f32edb783af649f34988edbc7bc019ade7170046d89e49e8dc4", + "third-party/nsh/website/zh-CN/elements/tabs.md": "11b8b533bea8f968f3dbc66e9326dc43048c43c8632ff1a3a6d261c3ea4b85c4", + "third-party/nsh/website/zh-CN/elements/text-selection.md": "bf7ed78fb2f657da6955f84dc278e8ae6263597d221c1366dfa1fc937435021b", + "third-party/nsh/website/zh-CN/elements/text-view.md": "b81d83a6d3c58ee1a4866612be676f5672cd86586755f7fbad2c772c9071a40a", + "third-party/nsh/website/zh-CN/elements/text.md": "3f46ee948e7e12c470cd55db4f8610d0c12017d6826f9038e76dd410f0aaa610", + "third-party/nsh/website/zh-CN/elements/time-field.md": "9a2df14fe38c405fab55b61bd07dd5fec42945ca38de9e19e353850697898b10", + "third-party/nsh/website/zh-CN/elements/toast.md": "947aebf4c8eabb7dee867387a98a95e990e0ffe03695d2eaa1f487b66f61dd15", + "third-party/nsh/website/zh-CN/elements/toggle.md": "2e43c28ad8d0ed30ad9e2a1b503b4498fe4a73ccfef31a9c1b45fb65aeb4758d", + "third-party/nsh/website/zh-CN/elements/toolbar.md": "db4b31e6fc1bc0eb530986a1fc355dd375c88e88c620991fa0ac0d7e57179761", + "third-party/nsh/website/zh-CN/elements/tooltip.md": "bf5211ca19292cc49541ef3535ef54f94e8609f79aa14d750ca4562f7a269dfd", + "third-party/nsh/website/zh-CN/elements/video.md": "11d46e40a3aed86235126d67bf936acfd1701f74479e52e05ea55f0acb8927f4", + "third-party/nsh/website/zh-CN/elements/view.md": "b4be7fa9ce6d04a78bc5b4b40fb8c88fc722c5cab11409c025ed431af17ef2f4", + "third-party/nsh/website/zh-CN/elements/web-view.md": "48e6acc43494745c73843a0b3254144bd9e59cdd9eae4ba0c48bec2aa61f35f3", + "third-party/nsh/website/zh-CN/modules/globals.md": "f68f79f7b6cfadc2caacde684b7bdea85e17c7da75c9f41aa9f815a5795d5331", "third-party/nsh/website/zh-CN/modules/index.md": "c968ab47632fc86cc8cea7e6d85f11e3ecf6cd20b7d5330605080384dd5616e0", - "third-party/nsh/website/zh-CN/modules/nsh-base.md": "944d0808bd1f255a0f9b0928d2ad7640a058e127e3553a1649ebd201fffaba15", + "third-party/nsh/website/zh-CN/modules/nsh-base.md": "cc14c6b8f5b8be6332736329cb26fa9ed999d7c8b279cbdaa78e0017e5bfe013", "third-party/nsh/website/zh-CN/modules/nsh-canvas.md": "d0668be7a841a758cc0c2f7a4e5605f4a492b75546df22ddf9628a3f4b3f0c0d", - "third-party/nsh/website/zh-CN/modules/nsh-desktop.md": "eea0a9f9175d95ab6e82160e784265806904ce3a0257efd21428d366fe0c919c", - "third-party/nsh/website/zh-CN/modules/nsh-image.md": "9c26267dce1d66a4540a86131e9151d4f35f6f8b38b751d2bfb8046d8fdc9912", + "third-party/nsh/website/zh-CN/modules/nsh-desktop.md": "f148f8f568ba5e192b374b716a81424ff41b036a76ae4d05b19111b3421f409a", + "third-party/nsh/website/zh-CN/modules/nsh-image.md": "c32520f6f82b7edcd5bfa36bd2695a766a0b33f7ebfd4aadb8851c58fce6bdce", "third-party/nsh/website/zh-CN/modules/nsh-list.md": "f30059245b00da4a4a3a6a3147acefff18efdf0896e47338494ca2688887a5ce", - "third-party/nsh/website/zh-CN/modules/nsh-mobile.md": "331cf20f56ca2e736e6dbf832d584fd0c0924731da87df29b055f133f2e955ce", + "third-party/nsh/website/zh-CN/modules/nsh-mobile.md": "2df8d3d623c22fa75bc7a6bc595dc81dca2c368a9c8bd9b5a9f773eea87a5a61", "third-party/nsh/website/zh-CN/modules/nsh-video.md": "76e631cff730928d60d5bf7e5bd9dde466c2738bcd1c999603ade669ed443a40", "third-party/nsh/website/zh-CN/modules/nsh-webview.md": "11ee96c889e7b3af845faf8fabcafe0efbfbd8fea801f0a2203f037f06c19be9", - "third-party/nsh/website/zh-CN/modules/nsh.md": "456e07cbdf4b61d71a021e7aef20b87ae82bd49533d5a5ef8095e14665ff1edf", - "third-party/nsh/website/zh-CN/modules/standard-modules.md": "e29f113962e1de87b8c2863276011149c0ad4bb14ad6fcc2aa824dc55f412202", + "third-party/nsh/website/zh-CN/modules/nsh.md": "f15caf4af7b3dc6dc682d43770d50ab9d7c22edec822b126f0bec95b3f6cd4d5", + "third-party/nsh/website/zh-CN/modules/standard-modules.md": "655857e1431ef978b861d723aed4ed86d2496a8fbe142fa46184851907a5ae8a", "third-party/nsh/website/zh-CN/modules/style-reference.md": "8ee16ea92d69da02493678a79aba09511adf82d87b7f8144718589b5e0142d9a", - "third-party/nsh/website/zh-CN/modules/tailwind-reference.md": "4573537d1aa123f79ab84d5753075b7df25fa27ab8d2d69e572f2ba10c2231b6", + "third-party/nsh/website/zh-CN/modules/tailwind-reference.md": "21682cd9b16c45cc0104ef8979ebb150e656105ddfb38ead97d0b9437de5b144", "third-party/playwright/LICENSE": "45873d00a0dd243596deb4aa23b2493b3d1f0671921bf2538ea431d7380220eb", "third-party/playwright/NOTICE": "6d602191187b35b9b01d2cffa01c8469c2c8d9de8a96f1bf868e0f264f51c81d", "third-party/playwright/README.md": "3f615fad2c9c8b25534ca2fc73d3ef99fde859ac312f9a1a4d122aa833922bab", diff --git a/products/maka/workbench/app/chrome.ts b/products/maka/workbench/app/chrome.ts index df4cc8eceb..af7dea8541 100644 --- a/products/maka/workbench/app/chrome.ts +++ b/products/maka/workbench/app/chrome.ts @@ -20,12 +20,11 @@ // The window around the workbench on a graphical desktop: the headers along // the top edge are its title bar, and the application menu holds the // workbench's commands. Terminals keep their own title and menus. -import { effect } from 'nsh'; -import { platform } from 'nsh'; +import { effect, platform } from 'nsh'; import { isAvailable, Menu, titleBar, Window } from 'nsh/desktop'; import { tr } from './locale'; -export const ownTitleBar = isAvailable && platform.idiom === 'desktop' && !platform.isTerminal; +export const ownTitleBar = isAvailable && platform.desktop; /** The headers' height, which the system's window controls are centered in. */ export const TITLE_BAR_HEIGHT = 48; diff --git a/products/maka/workbench/app/notifications.ts b/products/maka/workbench/app/notifications.ts index aa565da529..9c976e6b1a 100644 --- a/products/maka/workbench/app/notifications.ts +++ b/products/maka/workbench/app/notifications.ts @@ -20,7 +20,7 @@ // The desktop window's notifications: a conversation in the list that stops // working, or starts waiting for the user, while the window is in the // background. Choosing one shows that conversation. Terminals have none. -import { cleanup, effect, host as shell, untracked } from 'nsh'; +import { effect, host as shell, untracked } from 'nsh'; import type { BrowserSession } from './browsing'; import { ownTitleBar } from './chrome'; import { tr } from './locale'; @@ -58,8 +58,7 @@ export function watchNotifications(props: { .catch(() => undefined); } }); - const opened = shell.on('notification.open', (data: { tag?: unknown }) => { + shell.on('notification.open', (data: { tag?: unknown }) => { if (typeof data?.tag === 'string') props.open(data.tag); }); - cleanup(() => opened.remove()); } diff --git a/third-party/nsh/.github/workflows/release.yml b/third-party/nsh/.github/workflows/release.yml index a300cf3c84..37d5987407 100644 --- a/third-party/nsh/.github/workflows/release.yml +++ b/third-party/nsh/.github/workflows/release.yml @@ -127,7 +127,7 @@ jobs: with: targets: wasm32-unknown-unknown - # QuickJS's WebAssembly build needs upstream LLVM: Apple's clang + # The web runtime's C code (zstd) needs upstream LLVM: Apple's clang # does not target wasm32. Check the C tools before building runtimes. - name: Set up WebAssembly C tools run: | @@ -185,27 +185,6 @@ jobs: restore-keys: | ${{ runner.os }}-release-cargo- - # Compile the actual WASI headers before the native runtimes: a bare - # clang probe does not exercise quickjs-jit-sys's vendored include path. - - name: Check the Windows web runtime - if: runner.os == 'Windows' - run: | - cargo metadata --locked --format-version 1 --filter-platform wasm32-unknown-unknown > "$RUNNER_TEMP/wasm-metadata.json" - python - "$RUNNER_TEMP/wasm-metadata.json" <<'PYTHON' - import json - import pathlib - import sys - packages = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["packages"] - package = next(p for p in packages if p["name"] == "quickjs-jit-sys") - include = pathlib.Path(package["manifest_path"]).parent / "vendor/wasi-libc/include" - for name in ("stdlib.h", "bits/alltypes.h"): - header = include / name - if not header.is_file(): - sys.exit(f"Missing vendored WASI header: {header}") - print(f"Verified WASI header: {header}") - PYTHON - cargo build --profile web -p nsh-platform-web --target wasm32-unknown-unknown --features bytecode-only - - name: Build the distribution run: cargo xtask dist -v diff --git a/third-party/nsh/.github/workflows/rust-app.yml b/third-party/nsh/.github/workflows/rust-app.yml index 037bd9b4d3..d65a071c73 100644 --- a/third-party/nsh/.github/workflows/rust-app.yml +++ b/third-party/nsh/.github/workflows/rust-app.yml @@ -125,8 +125,8 @@ jobs: run: | mkdir -p "$RUNNER_TEMP/nsh-test-tmp" echo "TMPDIR=$RUNNER_TEMP/nsh-test-tmp" >> "$GITHUB_ENV" - # QuickJS is C: bindgen needs libclang, its WebAssembly build clang - # and llvm-ar. The describe program links the gallery's crate, + # QuickJS is C: bindgen needs libclang for the CLI; the web runtime's + # C code (zstd) clang and llvm-ar. The describe program links the gallery's crate, # whose native entry (`cargo run`) uses GTK 4 on Linux. - name: Install clang, LLVM and GTK 4 run: sudo apt-get update && sudo apt-get install -y clang llvm libclang-dev libgtk-4-dev @@ -178,6 +178,10 @@ jobs: for app in counter gallery rust-exports; do ./target/debug/nsh test "examples/$app" done + # NSH's generated declarations type-check themselves: apps skip + # checking them (`skipLibCheck`), which would hide a broken one. + - name: Type-check the generated declarations + run: ./target/debug/nsh check examples/counter -- --skipLibCheck false # The same tests in the runner's Chrome, headless, with the browser's # trusted input (`nsh test --target web`): those a real platform runs # (real time). diff --git a/third-party/nsh/.github/workflows/website.yml b/third-party/nsh/.github/workflows/website.yml index b7b134defc..b851fb6ba0 100644 --- a/third-party/nsh/.github/workflows/website.yml +++ b/third-party/nsh/.github/workflows/website.yml @@ -2,7 +2,9 @@ name: Website # Builds the documentation site with its live demos (the WebAssembly runtime, # `cargo xtask web`) and publishes it to GitHub Pages at nsh.dev -# (`website/public/CNAME`). +# (`website/public/CNAME`). App Stories (/apps) come from the reviewed +# catalog in longbridge/nsh-showcases, whose own workflow dispatches this one +# after each merge and its weekly star refresh. on: push: @@ -51,8 +53,8 @@ jobs: steps: - uses: actions/checkout@v4 - # QuickJS is C: clang compiles it for wasm32 (llvm-ar archives it), and - # bindgen needs libclang for the host build of the nsh CLI. + # The web runtime's C code (zstd): clang compiles it for wasm32 (llvm-ar + # archives it); bindgen needs libclang for the host build of the nsh CLI. - name: Install clang and LLVM run: sudo apt-get update && sudo apt-get install -y clang llvm libclang-dev @@ -95,6 +97,19 @@ jobs: CHROME=$("$RUNNER_TEMP/nsh-browser-tests/bin/python" -c 'from playwright.sync_api import sync_playwright; p=sync_playwright().start(); print(p.chromium.executable_path); p.stop()') "$RUNNER_TEMP/nsh-browser-tests/bin/python" crates/nsh-platform-web/web/browser/browser-check.py --chrome "$CHROME" --wasm target/wasm32-unknown-unknown/web/nsh_platform_web.wasm + # The published site requires the catalog (NSH_REQUIRE_SHOWCASES). + - name: Check out the App Stories catalog + uses: actions/checkout@v4 + with: + repository: longbridge/nsh-showcases + ref: main + path: .showcases + persist-credentials: false + + - name: Install the catalog's dependencies + working-directory: .showcases + run: bun install --frozen-lockfile + - name: Install website dependencies working-directory: website run: bun install @@ -106,11 +121,15 @@ jobs: working-directory: website env: NSH_REQUIRE_PLAY: "1" + NSH_REQUIRE_SHOWCASES: "1" + SHOWCASES_DIR: ${{ github.workspace }}/.showcases AR_wasm32_unknown_unknown: llvm-ar run: bun run build - name: Test website working-directory: website + env: + SHOWCASES_DIR: ${{ github.workspace }}/.showcases run: bun run test - name: Check standalone Gallery code size diff --git a/third-party/nsh/AGENTS.md b/third-party/nsh/AGENTS.md index 1f214014fe..7f61557f62 100644 --- a/third-party/nsh/AGENTS.md +++ b/third-party/nsh/AGENTS.md @@ -4,8 +4,9 @@ Native Shell (NSH for short in prose; crates, the CLI, the JS module and Java pa AI-generated, dynamically delivered native UI: native apps embed it (`NshAppView` on iOS, `NshView` on Android, `AppView` on macOS, Windows and Linux) and load TSX modules at run time — signed, sandboxed by the host, rendered with each platform's own UI. Embedding comes first; plugins for native apps and standalone apps use the same machinery. -Module code runs in QuickJS on a native runtime (the reactive graph, the renderer and every module apps import are -Rust); layout uses Taffy; embedding native components such as maps or web views has no layering problems. The +Module code runs in QuickJS (on the web, the browser's own JavaScript engine) on a native runtime (the reactive graph, +the renderer and every module apps import are Rust); layout uses Taffy; embedding native components such as maps or +web views has no layering problems. The toolchain is pure Rust and does not need Node.js; app developers get a prebuilt distribution (`cargo xtask dist`) and never need Rust either. Every platform has a native backend: iOS with UIKit views (`nsh-platform-ios`), Android with Android views (`nsh-platform-android`), macOS with AppKit views (`nsh-platform-macos`), Windows in Win32 windows drawn @@ -49,7 +50,7 @@ crates/ the standard modules among them (`runtime/fs.rs`: `fs/promises`, `runtime/process.rs`, `runtime/net.rs`, `runtime/websocket.rs`, `runtime/os.rs`) on the `System` their calls get (capabilities, data directory, quotas, per-VM state) nsh-base/ `nsh/base`, NSH's `gpui-base`: unstyled base components (behavior, state, focus, accessibility; the look is the - app's): navigation (`NavStack`, `NavPage`), `Dialog`, `AlertDialog`, `Button`, `Checkbox`, `Radio` / `RadioGroup`, `Switch`, `Tab` / `Tabs`, `Select` and `Combobox` (`select.rs`, `combobox.rs` on `listbox.rs`, the list's state machine they share), `Collapsible`, `Progress`, `Slider` (a value or a range along a track: `slider.rs`, numbers' rules in `numeric.rs`, a focused control's keys in `keys.rs`), `Scrollbar` (a bar of the app's own over a scroll view or list it names: `scrollbar.rs`, on the core's `watch_scroll`), `NumberInput` (a number typed or stepped: `number_input.rs`), `OtpInput`, `Table` and its parts (`table.rs`: a table's roles, the look the app's), the charts' parts in the submodule `nsh/base/plot` (`Plot`, `Line`, `Area`, `Grid`, `Pie`, `Arc`), `Input` (a native text input, made by `nsh-js`), the status + app's): navigation (`NavStack`, `NavPage`), `Dialog`, `AlertDialog`, `Avatar` / `AvatarGroup` (a picture or its fallback, the state from the image's own load events; a row that places its first `limit`: `avatar.rs`), `Button`, `Link`, `Checkbox`, `Radio` / `RadioGroup`, `Switch`, `Tab` / `Tabs`, `Toggle` / `ToggleGroup` (a toggle button and a toolbar of them: `toggle.rs`), `Toolbar` / `ToolbarGroup` / `ToolbarSeparator` (a bar of buttons, toggles and links: `toolbar.rs`; `roving.rs` the roving focus `Tabs`, `RadioGroup`, `ToggleGroup` and `Toolbar` share, its items in tree order through the core's `watch_order`), `Select` and `Combobox` (`select.rs`, `combobox.rs` on `listbox.rs`, the list's state machine they share), `Calendar` (`calendar.rs`: views, disabled dates, the days' keys), `TimeField` (`time_field.rs`: segments over one native text field), `DatePicker` (`date_picker.rs`: a calendar and time field in a popup; `popup.rs`'s `dropdown` is the popup `Select`, `Combobox` and `DatePicker` share), `Collapsible`, `Accordion` and its parts (`accordion.rs`: items open by the app's `value`, one at a time or several, panels revealed as `Collapsible`'s), `Progress`, `Slider` (a value or a range along a track: `slider.rs`, numbers' rules in `numeric.rs`, a focused control's keys in `keys.rs`), `Scrollbar` (a bar of the app's own over a scroll view or list it names: `scrollbar.rs`, on the core's `watch_scroll`), `NumberInput` (a number typed or stepped: `number_input.rs`), `ColorPicker` and its parts `ColorArea`, `ColorSlider`, `ColorField`, `ColorSwatch`, `ColorEyeDropper` (a color in RGB, HSL, HSB or OKLCH: `color_picker/`), `OtpInput`, `Table` and its parts (`table.rs`: a table's roles, the look the app's), the charts' parts in the submodule `nsh/base/plot` (`Plot`, `Line`, `Area`, `Grid`, `Pie`, `Arc`), `Input` (a native text input, made by `nsh-js`), the status bar, `TextView` (Markdown / HTML documents: `text_view/`, docs/dev/text-view.md), `ResizablePanelGroup` / `ResizablePanel` (`resizable/`), `DockArea` (a dockable workspace: splits, tab groups and edge docks whose layout is data, `dock/`, docs/dev/dock.md), `Editor` (a code editor: highlighting, several carets, folds, search, diagnostics; `editor/`, docs/dev/editor.md), `DiffView` (the changes between two texts or of a patch, split or unified: `diff/`, @@ -106,9 +107,10 @@ crates/ nsh-platform-web/ WebAssembly backend (published web apps, `nsh run --target web`, the website's live demos): the engine on the page's main thread (`LocalEngine`), text laid out with browser font metrics, batches as JSON + CSS for the DOM runtime in `web/nsh.js`, the animator on requestAnimationFrame, fetch over the browser's - (`net.rs`); feature `bytecode-only`: published apps' runtime (`nsh.wasm`, no QuickJS parser), feature - `dev`: the development host's (`nsh-dev.wasm`, source and hot updates, the model `nsh test --target web` - asks about); `web/index.html` is the demo + (`net.rs`); app code runs on the browser's own JavaScript engine (feature `browser`: `src/browser/` + and `web/browser/`, no QuickJS in WebAssembly), feature `dev`: the development host's (`nsh-dev.wasm`, + development bundles with hot updates, `web/browser/hot.js`, the model `nsh test --target web` asks + about); `web/index.html` is the demo player (`cargo xtask web`); feature `compiler`: the playground's TSX compiler (`nsh-playground.wasm`) (docs/dev/web.md) nsh-platform-linux/ GTK 4 backend (Linux): one `NshView` widget per node drawn with GSK, Pango text engine (typeset on the engine @@ -348,7 +350,13 @@ when changing text measurement, clipping, pointer input or scripts (`docs/dev/tu app's slot), or `docs/dev/api-naming.md` says why it does not. `gpui-component` ↔ nothing: a styled component library is never part of NSH; apps and the community build theirs on `nsh-base`, or style the UI in the app itself. Each export has one home (two registries exporting one name panic, `Registry::merged`). Mobile-only APIs (iOS, Android or both) go to `nsh/mobile`, desktop-only ones to `nsh/desktop`, - platform-only props into `ios={{…}}` / `android={{…}}` / `desktop={{…}}`. `nsh` has no catch-all re-exports. Beside + platform-only props into `ios={{…}}` / `android={{…}}` / `desktop={{…}}` (typed objects: a system feature + several platforms have goes in each of theirs under one name; what an element means everywhere is a top-level + prop, ignored where it does not apply). Desktop has one definition: a desktop OS (macOS, Windows, Linux) or the + web in the desktop idiom (`Environment::desktop()`), which `desktop={{…}}`, `platform.select({ desktop })`, + `platform.desktop` and `platform.idiom === "desktop"` all follow; Mac Catalyst is mobile (the iOS backend); + `nsh/desktop`'s `isAvailable` alone is narrower, the OSes with its windows and menus (`api-naming.md`). `nsh` has + no catch-all re-exports. Beside them sits a fixed catalog of eleven standard modules with bare specifiers (`nsh_core::js::STANDARD_MODULES`: `buffer`, `console`, `crypto`, `fs/promises`, `net`, `os`, `path`, `process`, `url`, `websocket`, `zlib`): Node-shaped subsets compatible with gpui-shell's, built on the same native machinery (typed registries, capability @@ -360,8 +368,10 @@ when changing text measurement, clipping, pointer input or scripts (`docs/dev/tu in [`longbridge/nsh-compat`](https://github.com/longbridge/nsh-compat) shipped is never removed or renamed: modules, exports and their members, `nsh:rt` (what compiled JSX calls: append-only, signatures and semantics kept), globals, props, events and payload keys, style keys and values, Tailwind utilities. A rename or move keeps the old name as a - `@deprecated` alias of the same implementation, for good. Types only widen; inputs gain optional members only; - behavior and defaults stay (new behavior is opt-in); formats (`.nshpkg`, certificates) are read forever. The record + `@deprecated` alias of the same implementation, for good. Types only widen, a union gaining members whichever way it + goes (what apps read too: `platform.os`, a state, a reason; apps handle a value they do not know); inputs gain + optional members only; behavior and defaults stay (new behavior is opt-in); formats (`.nshpkg`, certificates) are + read forever. The record and its checks live in that repository, never here: a change to NSH cannot rewrite them, and the only removals are security closures in its `closed.txt`. No release is recorded yet, so nothing is frozen: what is on `main` may still change until the first release recorded, which is not chosen yet (0.3.0 is not recorded). A new API lands with its test, a case in @@ -387,6 +397,10 @@ when changing text measurement, clipping, pointer input or scripts (`docs/dev/tu `active:`), no built-in strings (every label comes from the app); system UI keeps the platform's look (iOS / Android headers; `AlertDialog`, drawn in the platform's dialog style where the system has no dialog to show). React Native and UIKit are references for which capabilities exist, not APIs to be compatible with. +- **Pointer-driven motion follows the pointer in the same frame; transitions and springs are for discrete + changes.** A hover's cross line, a dragged thumb, a scroll bar, a resizer, a swipe track the pointer 1:1 and are + set, never eased toward it; keyboard steps, snaps on enter / leave, releases, fades and data changes animate + (`docs/dev/animation.md` "Pointer-driven motion follows the pointer"), in components and demos alike. - **Styles: native first, in the forms people write.** One set of properties written three ways (Rust `Styled`, `style` keys, Tailwind classes) means the same in each; the names are CSS's but for a few common forms. Each key takes the form most used and easiest to write, CSS's by default: where common use and CSS differ, the common form wins (a `lineHeight` number is @@ -415,8 +429,16 @@ when changing text measurement, clipping, pointer input or scripts (`docs/dev/tu `MouseEvent { position, pagePosition, modifiers, pressedButton }`; `onMouseMove` fires with or without a button down. State styles keep CSS's names (`hover:`, `hoverStyle`). - Returns: an object, never a bare value that may need a second field (`AlertDialog.show` → `{ action }`, - animations → `{ finished }`). Failure rejects (or throws) with the error, never resolves `false` (`Image.prefetch`, - `getSize`, `fetch`); yes / no questions (`hasText()`, `canUndo()`) return booleans. + animations → `{ finished }`); yes / no questions (`hasText()`, `canUndo()`) return booleans. + - Failures (of what JS calls): a function returning a promise never throws, every failure rejects (arguments, + grants, quotas, I/O, as Node's `fs/promises`); a synchronous one throws a `TypeError` / `RangeError` for a wrong + use. A failure the app can handle carries a stable `code` (`nsh_core::element::codes`: `ERR_ACCESS_DENIED` with + `permission` and `resource` for what the capabilities do not grant, an `Error` (`fetch`'s keeps the web's + `TypeError`), `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, `ERR_NSH_QUOTA_EXCEEDED`, `ETIMEDOUT`, `ERR_NSH_HOST_ERROR`, + Node's errno codes for I/O; `nsh/test`'s own `ERR_NSH_TEST_*`); messages are not API. Never a sentinel + (`false`, `-1`, `""`, `[]`) or silence for a failure; `null` stays an answer ("nothing there"), a cancel resolves + empty. A command that runs at the commit (a component's or ref's method) cannot throw: given an unknown id or + index it does nothing and logs a warning. - Methods whose parameters may grow take their options as one object (`net.connect({ host, port })`, `Window.setTitleBar({ style, height })`); parameters the user judged will not grow stay positional (`setSelection(start, end?)`, `seek(seconds)`, `DockArea.addPanel(panel, placement?, size?)`, @@ -425,8 +447,12 @@ when changing text measurement, clipping, pointer input or scripts (`docs/dev/tu - Identifiers an app matches on are the app's and required, never derived from a label (`AlertDialogAction.id`). - Refs: each element has a ref type of its own (`InputRef`, `ScrollRef`, `VideoRef`, ... over a common `ViewRef`), never one type carrying every element's methods. - - Listening: a function that starts listening returns a function that stops it, and what is registered while a - component renders goes with the component. + - Listening: the function that registers a listener is `on(event, listener)` in everything TSX calls + (`BackHandler.on("press", l)`, `host.on(name, l)`; Rust's own API keeps GPUI's `on_`), never an + `on(l)`, `subscribe` or `addListener`; it returns a function that stops it (`() => void`, a Rust + function's `Stop`), never `{ remove() }` or an id for an `off`, and what is registered while a component renders + goes with the component. `keymap.bind` keeps `bind` (a shortcut is not an event); the standard modules keep + Node's `on`, which returns its object. - **Components are Rust.** Rust components live in `crates/nsh-base` or in an app's own crate (its UI set, `#[native_module("…")]`), run in the core and render primitives with typed styles (GPUI-style `Styled` methods, never class strings); props and events are derived types, their JS face is made from the specs (`nsh-js` @@ -517,7 +543,8 @@ Details and examples: "Commits and pull requests" in `CONTRIBUTING.md`. workflow. A request to release or push changes does not authorize a direct push to `main`. - Subjects and PR titles are `: `: the area in lower case (a crate without `nsh-`: - `core`, `js`, `engine`, `bundler`, `cli`, `base`, ...; one platform: `ios`, `android`, `macos`, `windows`, + `core`, `js`, `engine`, `bundler`, `cli`, ...; never `base`: a change to `nsh-base` names its component in snake + case, `rich_editor`, `editor`, `input`, `data_table`, ...; one platform: `ios`, `android`, `macos`, `windows`, `linux`; `desktop` for macOS, Windows and Linux together, `mobile` for iOS and Android together; `runtime`, a module; `tailwind`, `text`, `docs`, `examples`, `gallery`). One area only, never several separated by commas: for a change that spans parts, judge it as a whole and name the part it is mostly about (a feature the core adds and the platforms diff --git a/third-party/nsh/CONTRIBUTING.md b/third-party/nsh/CONTRIBUTING.md index daedca0719..328b96fca7 100644 --- a/third-party/nsh/CONTRIBUTING.md +++ b/third-party/nsh/CONTRIBUTING.md @@ -117,7 +117,8 @@ Commit subjects and pull request titles name the area first, then say what chang ``` - The area is the part of NSH the change is about, in lower case: a crate without its `nsh-` prefix (`core`, - `animation`, `js`, `engine`, `package`, `bundler`, `dev`, `cli`, `xtask`, `base`), a platform (`ios`, `android`, `macos`, + `animation`, `js`, `engine`, `package`, `bundler`, `dev`, `cli`, `xtask`), a component of `nsh-base` by its name in snake case, never `base` (`rich_editor`, + `editor`, `input`, `data_table`), a platform (`ios`, `android`, `macos`, `windows`, `linux`), a JS layer or module (`runtime`, `list`, `animated`, `navigation`), or `tailwind`, `text`, `docs`, `examples`, `gallery`, `ci`. A change to all the desktop backends (macOS, Windows and Linux) is `desktop`, one to both mobile platforms (iOS and Android) is `mobile`. Name one area only, never several separated by commas: when a change spans several, look at it as a whole and name the diff --git a/third-party/nsh/Cargo.lock b/third-party/nsh/Cargo.lock index 76fcc641e5..bfa152337a 100644 --- a/third-party/nsh/Cargo.lock +++ b/third-party/nsh/Cargo.lock @@ -674,6 +674,17 @@ dependencies = [ "poly1305", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "cipher" version = "0.5.2" @@ -855,6 +866,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "core_maths" version = "0.1.1" @@ -2401,6 +2418,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -3338,6 +3379,7 @@ dependencies = [ name = "nsh-base" version = "0.3.0" dependencies = [ + "chrono", "html5ever", "imara-diff", "log", @@ -3412,6 +3454,7 @@ dependencies = [ "tar", "tungstenite 0.30.0", "ureq", + "wasmparser", "windows-sys 0.61.2", "zip", ] @@ -3445,6 +3488,7 @@ dependencies = [ "tokio-tungstenite 0.28.0", "unicode-linebreak", "unicode-segmentation", + "ureq", "url", "webpki-roots 1.0.9", "windows-sys 0.61.2", @@ -3655,7 +3699,6 @@ dependencies = [ "rustc-hash 2.1.3", "serde_json", "ttf-parser", - "ureq", "webkit6", ] @@ -3734,9 +3777,7 @@ dependencies = [ "nsh-core", "nsh-dev", "nsh-engine", - "nsh-js", "nsh-testing", - "quickjs-jit", "rustc-hash 2.1.3", "serde", "serde_json", @@ -3763,7 +3804,6 @@ dependencies = [ "rustc-hash 2.1.3", "serde_json", "ttf-parser", - "ureq", "url", "webview2-com", "windows 0.62.2", @@ -7679,6 +7719,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags 2.13.2", + "indexmap", +] + [[package]] name = "wayland-sys" version = "0.31.11" diff --git a/third-party/nsh/Cargo.toml b/third-party/nsh/Cargo.toml index ff991206b5..a4a412aa46 100644 --- a/third-party/nsh/Cargo.toml +++ b/third-party/nsh/Cargo.toml @@ -70,6 +70,8 @@ ttf-parser = "0.25" oxc_sourcemap = "9" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Calendar dates and times of day (`nsh-base`'s `Calendar`, `TimeField`, `DatePicker`). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } plist = "1.7" zip = { version = "8", default-features = false, features = ["deflate"] } image = { version = "0.25", default-features = false, features = ["png"] } @@ -100,6 +102,8 @@ flate2 = "1" brotli = { version = "9", default-features = false, features = ["std"] } zstd = { version = "0.14", default-features = false } tar = { version = "0.4", default-features = false } +# Pruning the web runtime to what an app uses (`nsh package --target web`). +wasmparser = { version = "0.262", default-features = false, features = ["std", "validate", "simd"] } base64 = "0.22" getrandom = "0.3" slab = "0.4" @@ -502,18 +506,26 @@ opt-level = 3 opt-level = 3 [profile.dev.package.usvg] opt-level = 3 +# Photos are decoded and shrunk to their views' sizes as they load: unoptimized, +# a 12-megapixel JPEG takes seconds. +[profile.dev.package.image] +opt-level = 3 +[profile.dev.package.zune-jpeg] +opt-level = 3 +[profile.dev.package.zune-core] +opt-level = 3 +[profile.dev.package.png] +opt-level = 3 +[profile.dev.package.fdeflate] +opt-level = 3 -# The web runtime (`nsh.wasm`): what pages download, so Rust is optimized for -# size (3.7 MB instead of 5.0, 1.0 MB with brotli); QuickJS keeps `-O3`, as -# the interpreter is the slowest link (it is 0.45 MB of the difference). +# The web runtime (`nsh.wasm`): what pages download, so it is optimized for +# size. [profile.web] inherits = "release" opt-level = "z" strip = true panic = "abort" -[profile.web.package.quickjs-jit-sys] -opt-level = 3 - # Same codec as ratatui-image; only its output sink is made fallible. [patch.crates-io] icy_sixel = { path = "third-party/icy_sixel" } diff --git a/third-party/nsh/SOURCE.md b/third-party/nsh/SOURCE.md index fdabf42751..595deeb27d 100644 --- a/third-party/nsh/SOURCE.md +++ b/third-party/nsh/SOURCE.md @@ -21,370 +21,130 @@ This directory initially imported the complete tracked source of [`M4n5ter/nsh`, `tui/terminal-backend`](https://github.com/M4n5ter/nsh/tree/tui/terminal-backend) -at `de176695240d550f2b303643b8019146c7989e7f`. The upstream project is +at `de176695240d550f2b303643b8019146c7989e7f`. The maintained upstream is [`longbridge/nsh`](https://github.com/longbridge/nsh). -`upstream.json` keeps that initial `repository` and `revision`. Its -`upstream_revision` records the latest integrated `longbridge/nsh` main revision: -`ded545337d66eee244f0b0426198ea4c2d472a9d`. The first upstream synchronization -starts from `6bb0b7908043132067456e4a21dd57c90a0bdce5`, already included in the -initial fork import. - -`files` records each imported file's SHA-256. A `file_sources` entry identifies -the repository and revision supplying that file's current import baseline; -files without an entry retain the initial fork source. These hashes describe -source bytes, not later Maka adaptations or conflict resolutions (for example, -the README retains the terminal backend description). This is a mixed-source -vendor tree, not a claim that one upstream revision contains the fork features. -The source, tests, resources, original licenses and independent workspaces are -retained. `SOURCE.md` and `upstream.json` are Maka's provenance files. - -The 0.3 API-home migration uses `nsh_core::element` and the root Rust prelude; -JavaScript host services live in `nsh`, mobile services in `nsh/mobile`, and -Input in `nsh/base`. Legacy external module names are not aliases. Selectable -views replace SelectionArea while retaining terminal cell-aware selection and -popup scope behavior. Application-owned Tooltip elements retain Maka's prior -bubble styling and reactive text; the obsolete string tooltip constructor is -not retained. Removal of the legacy Toast command does not remove terminal -`Surface::announce` visual accessibility announcements. Local retained-host, -focus-return and root-retirement safety, native keymap support, input editing, -and all fork-only resources remain part of the mixed-source tree. Deleted -selection, device, keymap and native-service pages move their local contracts -into the corresponding canonical documentation pages. - -The subsequent bounded synchronization adds live slot bindings and sole-child -slot adoption, explicit nonpressable keyboard stops, listbox/option accessibility -roles, native navigation fixes, and Pie/Arc marks. Slot adoption does not replace -Maka's explicit Button trigger actions: Rust component hosts remain excluded. -The terminal and GPUI view-stop adaptations retain native component focus -capability, input exclusions, popup scopes and retirement ownership. Android -accessibility role indices 34/35 are separate from the preserved mount opcodes -34 (retire), 35 (project) and 36 (component slots). Cartesian stationary-hover -geometry refresh remains separate from selection notifications, while radial -placement follows the pointer without requiring a finite Cartesian x value. - -The bounded RichEditor and typed-style synchronization through -`1849776f68ee41d73c08970fe530cf952451af13` -uses canonical typed styles for pointer events, text selection, line limits, -image fitting and box shadows. Direct Maka callers migrate with these APIs; -local selectable roots, source-backed TextView selection, Android mount opcodes -and terminal shadow rendering remain intact. Existing `ordered-float` 5.5.0 is -retained with the required core dependency edge rather than an unrelated upgrade. - -This is a breaking source and bundle migration, not a compatibility alias layer. -Primitive `pointerEvents` moves into typed `style`; Text `numberOfLines`, -`ellipsizeMode` and `selectable` become style `numberOfLines`, `textOverflow` and -`userSelect`, and Image `resizeMode` becomes `objectFit`. Selectable View roots -and component-owned selection props remain distinct. Text, Image and Input -state styles use their respective typed style domains; canonical `line-clamp-*`, -`truncate`, `select-*`, `pointer-events-*` and `object-*` classes resolve through -those same style rules. Legacy single-shadow fields become `boxShadow` layers. -Consumers must regenerate declarations and rebuild old JS bundles with the -matching compiler/runtime; old prop or style aliases are not restored. -Maka's content-sized composer uses CSS `flex: initial`, and its conversation -wrapper explicitly keeps column layout when setting `display: flex`. -Retained terminal examples and selection/overlay documentation use the same -canonical primitive styles. A compiled-engine regression checks their mounted -values and reactive updates. A separate bundler smoke test compiles the retained -terminal workbench entry and its real import/asset graph. Neither test replaces -application TypeScript checks or mounted example UI coverage. - -The subsequent keymap synchronization adds `try_os()` and constructs upstream -RichEditor fixtures inside their engine. Public keymap factories follow the -current host keyboard convention, with compilation-target fallback outside an -engine. Maka retains its event-time dual tables for off-engine sessions, terminal -and Apple-browser policies, and custom binding precedence. - -The following units synchronization makes responder velocities points per second, -keyboard animation durations milliseconds, and viewability thresholds fractions -from zero to one under `itemVisibleThreshold` / `viewAreaCoverageThreshold`. -Image and video intrinsic pixel extents use `PixelSize` in declarations and -`Size` in Rust image callbacks; layout `Size` stays distinct. -These are breaking API/unit changes: regenerate declarations, migrate thresholds -and rebuild existing bundles. Media playback time remains seconds and decay's -per-millisecond retention remains unchanged. Native keyboard adapters convert at -the UIKit/Android boundary, not again in the engine. Local list retirement, -retained-value ownership, terminal image layout and keymap policy are preserved. -Additional regressions cover fractional threshold boundaries and JS forwarding, -JS responder velocity, and typed/map image-load pixel payloads. Preparing this -source does not establish test execution or iOS/Android device behavior; native -keyboard timing requires the corresponding supported platform validation. - -Independent local corrections retain RichEditor geometry on its owning thread, -retire its session subscription before dropping the geometry payload, and keep -host-specific keyboard conventions and explicit event policy. Plot stationary -geometry refresh also supports horizontal Bar bands without duplicate hover -notifications. Headless view focus commands follow mount-item order so later -blur and retirement decisions observe the current owner. The RichEditor terminal -sink uses the same leaf `BoxNone` and native post-mount `autoFocus` approach as -the code Editor; graphical backends and existing focus admission checks are not -weakened. These are local quality adaptations, not upstream import bytes. - -The trailing-whitespace measurement integration preserves `TextEngine::measure_canvas` -for platform-specific advances (including the terminal font catalog), while -using upstream whitespace-inclusive measurement when that hook is absent. -Upstream selection/link geometry regressions and the local canvas override -regression are both retained. - -The Editor / DiffView import adds their headless regressions, shared syntax -highlighting, per-engine registry setup, and pointer-event payload support. -Maka already passes `nsh_base::registry()` to its bundler and engine; these -components use that registration path rather than a second application runtime. -The iOS press bridge keeps the local owned `TextLink` representation while -forwarding upstream touch positions, modifiers and click counts. The shared -Unicode segmentation dependency follows upstream 1.13 for both the editor and -terminal backend. These integrations do not replace the local terminal input, -IME, completion or fallback-key capabilities described below. - -The DataTable / Table import adds virtualized row and column views, table -accessibility roles, and pressable focus notifications through the existing -registry. The focus declarations retain the terminal scroll/list events and -input `focusable` behavior; headless keyboard focus coexists with the local -native contact and input-result tracking. Editor scheduling and idle collection -follow upstream's large-file work, without claiming its benchmark figures as -Maka measurements. The terminal input and lifecycle adaptations remain intact. - -The pull-to-refresh import retains the terminal backend's existing physics and -48-point default refresh inset. A custom indicator uses its supplied height, -hides terminal spinner text, and receives pull distance through the existing -native animation value path. The iOS refresh replacement keeps pending-reveal -invalidation and the owned link-target representation. Shared text-engine hit -testing does not replace terminal cell-aware selection, IME or focus handling; -the new trait defaults estimate positions when a backend has no exact override. - -The row-accessor import adds retained JS row values and typed key/function -accessors, plus timing and spring props. Generic component props default to -`any` when no rows infer their type; the local array/list-source overloads keep -their existing inference behavior. Component methods mark trailing `Option` -arguments optional in TypeScript. Their `Param` and `MethodSpec::params` tuples -now include the optional flag; Maka's bindings use the deriving macros rather -than constructing those tuples. Empty first canvas recordings are suppressed, -while an empty recording still clears an existing drawing. -The retained-row adaptation also treats a different JS retention handle as a -prop change even when numeric columns match, preserving arbitrary row fields. -QuickJS returns retained values only to their owning function table; foreign -hosts receive no value. Custom `JsHost` implementations that retain values must -implement `owns_retained` for their own handles. Rust-created rows retain their -numeric equality semantics; this is not deep JavaScript object comparison. - -The local hint-observation integration retains an allocation lifetime separate -from the upstream layout tree's reusable slab index. This prevents a delayed -pointer observation from activating a newly allocated hint or anchor; the -existing hint-lifetime regression covers release and reuse. Keep this adaptation -until an upstream owner-identity mechanism provides the same guarantee. -The list integration also applies published source counts and splices before -issuing more paced bindings. This retains the fork's source-publication fence -while using upstream visible-first scheduling; regressions cover source changes -inside both visible and overscan row constructors. - -The local GPUI text-input bridge exposes weak native editor identities through -`TextInputTarget`, including fields sharing one `AppView` focus handle. Targets -include the existing GPUI focus generation and the native `FOCUS` owner's weak -admission token, so leaving and returning to a field revokes its old target. -`TerminalPlatform::apply_composition` applies a committed prefix and remaining -preedit with selection offsets in UTF-16 code units, rechecks the original -editor after committing, and cancels temporary marks without committing them. -NSH owns the editor model and pixel rendering. Maka owns ordered delivery, -application admission and source-composition binding for the experimental -OSC 777 `nsh-text-input` extension; a matching terminal-side IME producer must -supply the composition snapshots. These local adaptations do not establish -stock-terminal support or end-to-end OS IME validation. - -The code `Editor`'s terminal input sink uses `BoxNone` to leave pointer hits -with the underlying content while remaining a keyboard focus target. Its native -`autoFocus` prop handles initial focus after mounting; an early controller focus -request can precede attachment. Other renderers retain their existing sink -behavior, and terminal scope, disabled, ancestor and retirement checks remain -unchanged. This is a local integration correction, not an upstream sync. - -The GPUI and terminal backends also implement node-scoped `Input.replaceRange` -for completion edits. The command checks the observed native edit count, -original text and UTF-16 selection before replacing a range in one undoable -edit; it does not simulate IME or clipboard input. Other backends retain their -existing unsupported-command behavior. These changes are local adaptations, -not changes to the recorded upstream import baseline. - -The GPUI and terminal input editors also accept generic `tokens` rules. Native -Rust-regex captures derive styled ranges from the current committed source; -atomic ranges share caret, selection, replacement and deletion boundaries. The -native editor retains only the currently unfinished token beside its existing -text/selection undo snapshots. Plain text remains authoritative, and IME -preedit retains its existing separate display mapping. This is a local input -capability, not a Maka-specific parser or a hidden rich-text document format. - -The terminal and GPUI backends support `keymap.bind(..., {fallback: true})`. -These bindings run after native controls, text editing, selection and navigation -have left a key unhandled, preserving native copy and earlier scoped bindings. -The backends declare this capability through `Environment.fallback_key_bindings`; -unsupported hosts reject fallback bindings. This local adaptation supplies a -generic input phase; application interrupt and exit policy remains with Maka. - -The GPUI `AppView::paste` entry delivers a host-provided user paste through the -same focused-editor policy as the system clipboard. Terminal bracketed pastes -therefore report `onPaste` before the mounted `pasteBehavior` decides insertion, -while ordinary character and IME commits retain their text-input path. -Both backends interpret a complete pasted path list as file URIs only for an -input listening for file drops. Other input fields keep their literal text. -The file content is reported once through `onPaste`, or through `onFileDrop` -when no paste listener is mounted, without inserting the paths as text. - -A local declaration adaptation names the private CSS alignment helper -`LayoutAlign`, distinct from the public overlay `Align`. Its four layout -property descriptors keep the same accepted values; runtime parsing is -unchanged. - -The GPUI backend delivers files dragged in from other applications -(`file_drop.rs`): while a drag is active, `AppView` lays a layer over the -surface whose drag moves and drop drive `onFileDragEnter`, `onFileDragLeave` -and `onFileDrop` as the native backends do. This is submitted upstream as -longbridge/nsh#591; take upstream's version when a sync brings it, keeping -the local rule that paused input (`AppView::pause_input`) lays no drop layer. - -A live code block's header starts with the language its fence names, as the -`codeBlockLanguage` part, and ends with its actions. This is a local adaptation -of the fork's live code block, which upstream does not have. - -Maka's workspace uses these local crates, including NSH's offscreen renderer and -image codec; changing them here changes the code built and tested with Maka. - -The bundler has a local Windows network-share adaptation: regular and hot entry -paths use an ordinary UNC spelling only when re-canonicalizing it proves the -same native file identity. This keeps entry, working-directory and plugin paths -consistent for Rolldown without copying sources or changing the import baseline. -The conversion preserves native path components, including non-Unicode names. - -A manual dispatch of Maka's existing CI also runs the nested NSH core, base, -engine, JS, headless (with `agent`), terminal, bundler and development-tooling -(client/server) tests, followed by Web's portable `text` integration test. -This separate runner starts after Maka's test job, even if that job fails; -root workspace tests alone do not select these suites. A final, capacity-guarded -step selects the three animated-prop queue tests and the explicit-focus ownership -test in the owning GPUI workspace, verifies the three queue names and the exact -focus-test name, and runs them serially without opening a native window. The -focus test uses the production mounter, stop collection and activation helpers; -it does not validate OS-window focus dispatch. That workspace uses its own lock -and patches, not Maka's complete root dependency -graph; sharing a target directory does not imply equivalent cached artifacts. -Both vendored GPUI owning workspaces (`crates/gpui` and `examples/embed-gpui`) -explicitly reuse Maka's existing GPUI core, native platform, scheduler and -`async-io` path patches. The preserved input-target admission and cancellation -wake contracts depend on those adaptations; registry GPUI alone does not expose -them. These workspaces therefore require the containing Maka checkout, without -changing the root shipped dependency graph or disabling the local IME support. -The portable Web test does not validate browser rendering, and these selected -tests do not validate native platform runtimes or the `bundle`-gated testing -suite. A storage guard or timeout leaves the GPUI coverage incomplete. - -Develop and verify changes in this tree together with their Maka callers. Send -reusable changes to `longbridge/nsh`. When synchronizing the fork or upstream, -review local differences against the recorded revision, use upstream APIs and -recommended patterns where appropriate, and remove patches that upstream has -superseded. Refresh the recorded source hashes to the newly imported revision; -they describe the import, not later Maka modifications. - - -The Plot and shared-gallery import keeps the base library unstyled: shared demo -finishes live in the private `examples/nsh-gallery-ui` source package, without -new external dependencies. Native component animated props use stable named -slots. The terminal backend evaluates those slots through its existing Graph, -retains prop-response events and intrinsic sizing, and resets delivered values -when bindings or component identity change. Android reserves opcode 36 for -component slots, preserving the local retirement and root-projection opcodes -34 and 35. These are local wire adaptations, not upstream source hashes. -Host theme tokens are available before first JS evaluation without dropping -other queued engine messages. The existing terminal lifecycle, retained-row -host-identity checks and focus regressions remain in place. Native platform -runtime and gallery appearance checks require their supported environments; -Linux core tests are not evidence of those visual checks. - -Trusted Workbench modules use the existing chunk runtime through the public -`importModule` API and an optional `EngineConfig.chunk_loader`. `ChunkSource` -carries the module's asset directory; imported images and fonts resolve against -`import.meta.assetBase`. Engines own asynchronous loads and cancel them when -they close. Terminal `AppView::load_with_config` retains the caller's services, -assets and module loader while using the actual terminal geometry. - -Bundled resources now expose their logical package path and physical source -separately. Content-based resource families include every bitmap density -variant, preserving sibling filenames without depending on the checkout path. -Packaging, development serving, file watching and lazy font assignment use this -mapping, including imports outside the entry directory. GPUI and terminal input -fences let a Workbench replacement drain admitted edits before saving its draft; -they reuse the existing input scopes and engine message ordering. - -The synchronization through `d4c43ea6` adds the Shader view, held typed-ahead -text in GPUI fields, arrow-key movement in `Tabs` and `RadioGroup`, one unit -per quantity, Android density remount, -`nsh/base` registry parts and the browser-engine web runtime. The GPUI hold -queue wraps the local `set_focus_target` transition, so inline-link targets and -focus admission tokens remain; editable focus moves arm it, and hover hint -observations are not counted as engine events. Core creation and remount share -`mount_state`; list cells keep the local publication path, and a remount does -not clear a list's mounted cells. Only `sticky_ranges` is added beside the -local sticky handling. `TextViewLinkTarget` registers with the `text_view` part. -Android's Shader mount item takes opcode 37, after the local 34 to 36. The -terminal backend ignores Shader mount items, so a shader view shows its -background and children. `wgpu` and `naga` follow version 30, which Maka's GPUI -build already uses, instead of upstream's 29; the shader readback keeps its -previous frame when wgpu 30 cannot map a range. Maka's root dependency graph -gains no packages from this synchronization. RichEditor's platform keys keep -the keymap synchronization's host keyboard convention above. - -Local input corrections retain IME replacement ranges, marked selections and -composition cancellation while input waits for the engine. Delayed key releases -follow their presses; reload drains the admitted operations before saving the -draft. Pending edits have no time-based expiry, and a settled nonediting target -does not intercept ordinary button activation. - - -The subsequent breaking migration through -`ded545337d66eee244f0b0426198ea4c2d472a9d` starts from the recorded raw -`d4c43ea6bece16881a582275fdbeb754bebb3e4f` baseline. Its 610 changed upstream -paths include 58 additions and no deletions. Import hashes record exact upstream -bytes; every other historical source record remains unchanged. The integration -preserves Maka's existing version-30 GPU graph and the later range-aware GPUI -input queue, focus settlement, control-key ordering and nested indicators. - -JavaScript events now carry named objects: Input change uses `{ value }`, layout -uses `{ bounds }`, image load uses `{ size }`, and Dialog/Popover changes use -`{ open, reason }`. Animation completion resolves `{ finished }`, stop resolves -`{ value }`, opening a URL resolves `{ opened }`, and AlertDialog resolves -`{ action }` with explicit action ids. Animated `getValue()` remains numeric. -Element-specific refs replace `YNode`; the local replacement method stays on -`InputRef`. `Window.setTitleBar` takes options, and public Rust element helpers -come from `nsh_core::element`, not its private `ambient` module. Direct callers -and retained examples migrate with their actual owner types and payloads; -declarations and bundles must be rebuilt, without legacy aliases. - -RichEditor table geometry extends the owner-thread proxy rather than restoring -transferable `Rc` payloads. Local dual keyboard tables, source-backed TextView -selection, List projection/retirement and terminal focus behavior remain. -Themed input bars receive actual terminal viewport reports, including replacement -at an unchanged offset; programmatic scrolling does not change selection. -Canonical mouse events preserve passive hints and held-button modifiers. State -flags remain 16-bit through retained backends. Regression fixtures deliver real -component layout callbacks and observe ProjectRoot attachment and transformed -thumb geometry, rather than substituting an unrelated mount item. - -Independent local quality changes clear retired Shader readbacks, invalidate -static/paused native shader sizes before tick admission, and retire Web shader -canvases, observers and owned animation frames on delete, reset and stop. The -Web stop reentrancy guard and browser-host shutdown remain intact. Same-row Plot -domain updates move anchors through the domain tween alone; pointer springs -remain for selected-row changes. A terminal shadow guard is simplified without -changing interpolation. These are local adaptations, not raw import bytes. -Source preparation does not establish fresh runtime, browser/native-device or -publication success; validation is tied to its actual source and environment. - -Built-in List scrollbars remain independent decoration roots rather than -recycling cells. Native attachment includes these decorations, and projected -rows keep the bar as their final following anchor so it stays above content -across rebinds and theme replacement. Select integration observers account for -`ProjectRoot` as well as `Insert` while preserving backdrop, focus and geometry -assertions. These local integration corrections do not change the recorded -`ded545337d66eee244f0b0426198ea4c2d472a9d` import bytes or source progress. - -`AppView::pressable_bounds` is a local addition for Maka's real-Host desktop -tests: it returns where the views with a given `accessibilityLabel` that take -presses are in the window, from the same tree an assistive technology reads, -so a test clicks them through the window's platform input. +The complete upstream tree is synchronized through main revision +`a5036af48ddc193b5023ece9b9196c76b8f8d3f5`. Earlier selective imports are +consolidated into this baseline; the terminal backend and other local +adaptations remain in this tree. + +`upstream.json` retains the initial fork's `repository` and `revision`. +`upstream_revision` records the integrated upstream revision. `files` contains +SHA-256 hashes of original imported bytes; `file_sources` identifies the +repository and revision of each upstream file's baseline. Fork-only files keep +their original hashes and implicit initial-fork source. Merged local content +is never substituted for upstream source bytes. Upstream deletions remove their +source records. The source, tests, resources, original licenses and independent +workspaces are retained; `SOURCE.md` and `upstream.json` are Maka provenance files. + +## Current upstream integration + +The latest integration uses a three-way merge against each file's recorded +baseline, rather than assuming that previous selective imports shared one +revision. Reusable changes should go to `longbridge/nsh`; later synchronizations +should replace local patches when upstream provides their required behavior. + +Maka and retained examples use the current canonical API directly: Rust element +helpers come from `nsh_core::element` and its prelude; JavaScript host services +live in `nsh`, mobile services in `nsh/mobile`, and Input in `nsh/base`. Named +event payloads, element-specific refs and typed styles replace old shapes. +Upstream `ItemAlign` replaces the local `LayoutAlign` declaration helper. +Subscriptions use the unified `on()` API and its returned stop function, with +reactive-owner cleanup; callers no longer expect subscription objects with +`remove()`. Migrate consumers and rebuild declarations and bundles together, +then remove superseded APIs rather than retaining aliases. + +`platform.desktop` is the canonical graphical-desktop check, including desktop +browsers and excluding terminal rendering. `platform.idiom` describes the +device, so a terminal on a desktop OS may still report `"desktop"`. +`platform.select` on terminals selects `terminal`, the host OS, `native`, then +`default`; `nsh/desktop.isAvailable` includes the terminal title and menu APIs. + +The GPUI backend uses upstream's shared image loader while retaining isolation +between host asset loaders and reload identities. Reads retain priority and +cancellation without a fixed queue-length rejection; local files keep their +existing unrestricted read path, while HTTP responses retain the download +size boundary. List integration keeps the +local source-publication fence, `ProjectRoot` projection and `RetireListCell` +ownership model. The existing model already preserves keyed row owners when +indices change, covering upstream #740 without a second item-memo mechanism. +Built-in List scrollbars remain independent decoration roots and stay above +projected rows across rebinds and theme replacement. + +## Retained local capabilities + +The terminal backend (`nsh-platform-tui`), terminal hosts, packaging, examples, +fonts and terminal-specific documentation remain maintained here. Terminal +selection and text measurement retain cell-aware behavior and the +`TextEngine::measure_canvas` override. Selectable roots, popup scopes, +focus return and root retirement retain their existing ownership rules. +Code Editor and RichEditor terminal input sinks use `BoxNone` for pointer hits +and native post-mount `autoFocus`. The terminal backend evaluates native +component animated slots and ignores Shader drawing while retaining its +background and children. + +GPUI input targets carry weak editor identities, focus generations and native +owner admission tokens. Delayed edits cannot move to a replacement editor. +Composition preserves UTF-16 replacement ranges, marked selections and +cancellation; held key releases follow their presses. Reload drains admitted +operations before draft saving, without time-based edit expiry. NSH owns the +editor and rendering; Maka owns ordered delivery and admission for the +experimental OSC 777 `nsh-text-input` extension. This does not establish +stock-terminal IME support. + +GPUI and terminal input support `InputRef.replaceRange` for one undoable +completion edit after checking the observed source, selection and edit count. +Generic input `tokens` derive styled and atomic ranges from committed text; +plain text remains authoritative and IME preedit stays separate. Scoped +`keymap.bind` fallback bindings run after native editing, selection and +navigation leave a key unhandled. Host keyboard conventions, explicit event +policy and custom binding precedence remain local contracts. + +Host-provided GPUI paste and terminal bracketed paste follow the focused +editor's existing `onPaste` and `pasteBehavior` policy. A complete pasted path +list becomes files only for inputs listening for file drops, and is delivered +once without inserting path text. GPUI external file dragging remains a local +adaptation submitted as upstream #591; paused input installs no drop layer. +`AppView::pressable_bounds` lets Maka's real-Host tests locate accessible +pressable views through the production window tree. + +Retained JS row values return only to their owning function table, and a changed +retention handle remains a prop change even when numeric columns match. +`Registry::value` provides lazy host-owned values shared by imports and +`nativeModule` within one VM. Layout observation uses allocation lifetimes +independent of reusable slab indices. RichEditor geometry remains owned by +its thread and retires its subscription before dropping the payload. + +Workbench modules use `importModule` and `EngineConfig.chunk_loader` through +the existing chunk runtime. `ChunkSource` provides the module asset directory +for `import.meta.assetBase`; engine shutdown cancels outstanding loads. +Terminal `AppView::load_with_config` retains host services, assets and the +loader while using actual terminal geometry. Bundled assets retain distinct +logical package paths and physical sources; content-based resource families +include bitmap density variants. Packaging, serving, watching and lazy font +loading use the same mapping. Windows UNC adaptation proves native file +identity before changing Rolldown entry spelling. + +Android preserves local mount opcodes 34 (retire), 35 (project), 36 (component +slots) and 37 (Shader), separately from accessibility role indices. Maka keeps +its version-30 GPU dependencies and GPUI patches for input admission and +cancellation. The vendored GPUI owning workspaces reuse those containing-Maka +patches. Live code blocks retain their language header and actions; shared +gallery styling stays in the private `examples/nsh-gallery-ui` package. + +## Verification and future synchronization + +Develop and verify the vendored code together with its Maka callers. Reuse +Maka's existing CI for freshly generated declarations and application +TypeScript checking, plus its nested NSH suites; root workspace tests alone do +not select the independent workspaces. The nested checks cover core, base, +engine, JS, headless, terminal, bundler and development tooling, portable Web +text behavior and selected offscreen GPUI ownership/queue behavior. Native +window dispatch, browser rendering and device behavior require their supported +environments; source synchronization does not itself establish those results. + +For the next sync, compare each file with its recorded upstream baseline, +merge still-required local behavior, migrate consumers to upstream APIs and +refresh hashes from the original upstream tree. This uses the existing source +records and CI rather than adding a separate synchronization framework. diff --git a/third-party/nsh/crates/gpui/Cargo.lock b/third-party/nsh/crates/gpui/Cargo.lock index d9dac0d48a..d352f2aa7c 100644 --- a/third-party/nsh/crates/gpui/Cargo.lock +++ b/third-party/nsh/crates/gpui/Cargo.lock @@ -5199,6 +5199,7 @@ dependencies = [ name = "nsh-base" version = "0.3.0" dependencies = [ + "chrono", "html5ever 0.27.0", "imara-diff", "log", @@ -5256,6 +5257,7 @@ dependencies = [ "tokio-tungstenite", "unicode-linebreak", "unicode-segmentation", + "ureq", "url", "webpki-roots 1.0.9", "windows-sys 0.61.2", @@ -5397,7 +5399,6 @@ dependencies = [ "ttf-parser", "unicode-bidi", "unicode-linebreak", - "ureq", "webkit2gtk", "windows-sys 0.61.2", "x11rb", diff --git a/third-party/nsh/crates/gpui/Cargo.toml b/third-party/nsh/crates/gpui/Cargo.toml index 68161e9e1e..316f0eaf77 100644 --- a/third-party/nsh/crates/gpui/Cargo.toml +++ b/third-party/nsh/crates/gpui/Cargo.toml @@ -47,7 +47,6 @@ log = "0.4" rustc-hash = "2.1" serde_json = "1" ttf-parser = "0.25" -ureq = "3.1" futures-channel = "0.3" futures-util = { version = "0.3", default-features = false, features = ["std"] } diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/Cargo.toml b/third-party/nsh/crates/gpui/nsh-platform-gpui/Cargo.toml index 916bb3bef5..92314c1f03 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/Cargo.toml +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/Cargo.toml @@ -36,7 +36,7 @@ log.workspace = true # Empty off the desktop (like the other platform crates off their targets). [target.'cfg(any(target_os = "linux", target_os = "macos", windows))'.dependencies] -nsh-core = { workspace = true, features = ["svg"] } +nsh-core = { workspace = true, features = ["svg", "loader"] } nsh-engine = { workspace = true, features = ["net", "shader"] } nsh-base = { workspace = true, features = ["highlight"] } gpui.workspace = true @@ -49,7 +49,6 @@ serde_json.workspace = true ttf-parser.workspace = true unicode-bidi.workspace = true unicode-linebreak.workspace = true -ureq.workspace = true anyhow.workspace = true crossbeam-channel.workspace = true futures-channel.workspace = true diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/access.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/access.rs index dbb7ce0bf6..934d339caf 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/access.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/access.rs @@ -88,12 +88,14 @@ use std::{ use gpui::{ A11ySubtreeBuilder, AnyElement, App, AvailableSpace, FocusHandle, InteractiveElement, IntoElement, StatefulInteractiveElement, Styled, Window, - accesskit::{self, Action, ActionData, CustomAction, Live, Node, Role, ScrollUnit, Toggled}, + accesskit::{ + self, Action, ActionData, AriaCurrent, CustomAction, Live, Node, Role, ScrollUnit, Toggled, + }, div, point, px, size, }; use nsh_core::{ - Accessibility, AccessibilityRole as AxRole, Checked, Command, EventKind, EventMask, LiveRegion, - MountBatch, NodeId, ViewKind, geometry::Points, + Accessibility, AccessibilityCurrent, AccessibilityRole as AxRole, Checked, Command, EventKind, + EventMask, LiveRegion, MountBatch, NodeId, ViewKind, geometry::Points, }; use rustc_hash::FxHashMap; @@ -138,7 +140,10 @@ fn groups_role(role: Role) -> bool { | Role::Switch | Role::Tab | Role::MenuItem + | Role::MenuItemCheckBox + | Role::MenuItemRadio | Role::ListBoxOption + | Role::TreeItem ) } @@ -168,6 +173,9 @@ fn focusable(role: Role) -> bool { | Role::Switch | Role::Tab | Role::ListBoxOption + | Role::MenuItemCheckBox + | Role::MenuItemRadio + | Role::TreeItem ) } @@ -236,6 +244,13 @@ pub(crate) fn role_for(a: &Accessibility, kind: ViewKind, pressable: bool) -> Op AxRole::RowGroup => Role::RowGroup, AxRole::Listbox => Role::ListBox, AxRole::Option => Role::ListBoxOption, + AxRole::Group => Role::Group, + AxRole::Region => Role::Region, + AxRole::Navigation => Role::Navigation, + AxRole::Tree => Role::Tree, + AxRole::TreeItem => Role::TreeItem, + AxRole::MenuItemCheckbox => Role::MenuItemCheckBox, + AxRole::MenuItemRadio => Role::MenuItemRadio, }) } @@ -500,13 +515,28 @@ fn describe_props( if state.selected || matches!(role, Role::Tab | Role::ListBoxOption) { node.set_selected(state.selected); } - if let Some(checked) = state.checked { + // A button's pressed state makes it a toggle button (ARIA's + // `aria-pressed`), as AccessKit reads a toggled button. + if let Some(checked) = state.checked.or(state.pressed) { node.set_toggled(match checked { Checked::False => Toggled::False, Checked::True => Toggled::True, Checked::Mixed => Toggled::Mixed, }); } + if let Some(current) = state.current { + node.set_aria_current(match current { + AccessibilityCurrent::True => AriaCurrent::True, + AccessibilityCurrent::Page => AriaCurrent::Page, + AccessibilityCurrent::Step => AriaCurrent::Step, + AccessibilityCurrent::Location => AriaCurrent::Location, + AccessibilityCurrent::Date => AriaCurrent::Date, + AccessibilityCurrent::Time => AriaCurrent::Time, + }); + } + if let Some(level) = a.level { + node.set_level(level as usize); + } if let Some(expanded) = state.expanded { node.set_expanded(expanded); } @@ -1605,6 +1635,75 @@ mod tests { assert_eq!(node(&built(&root), 6).label(), Some("Store")); } + /// The roles of gpui-base's structural components; a button's pressed + /// state, a level and a current item. + #[test] + fn structural_roles_pressed_level_and_current() { + let with_role = |role| Accessibility { + role, + ..Default::default() + }; + for (role, expected) in [ + (AxRole::Group, Role::Group), + (AxRole::Region, Role::Region), + (AxRole::Navigation, Role::Navigation), + (AxRole::Tree, Role::Tree), + (AxRole::TreeItem, Role::TreeItem), + (AxRole::MenuItemCheckbox, Role::MenuItemCheckBox), + (AxRole::MenuItemRadio, Role::MenuItemRadio), + ] { + assert_eq!( + role_for(&with_role(role), ViewKind::View, false), + Some(expected) + ); + } + let root = view(0, ViewKind::View, Accessibility::default()); + root.set_invalidate(Rc::new(|| {})); + let toggle = pressable( + 2, + Accessibility { + role: AxRole::Button, + label: Some("Bold".into()), + state: AccessibilityState { + pressed: Some(Checked::True), + ..Default::default() + }, + ..Default::default() + }, + ); + let heading = view( + 4, + ViewKind::View, + Accessibility { + role: AxRole::Header, + label: Some("Details".into()), + level: Some(3), + ..Default::default() + }, + ); + let page = pressable( + 6, + Accessibility { + role: AxRole::Link, + label: Some("2".into()), + state: AccessibilityState { + current: Some(AccessibilityCurrent::Page), + ..Default::default() + }, + ..Default::default() + }, + ); + toggle.insert_before(&root, None); + heading.insert_before(&root, None); + page.insert_before(&root, None); + let b = built(&root); + assert_eq!(node(&b, 2).role(), Role::Button); + assert_eq!(node(&b, 2).toggled(), Some(Toggled::True)); + assert_eq!(node(&b, 4).role(), Role::Heading); + assert_eq!(node(&b, 4).level(), Some(3)); + assert_eq!(node(&b, 6).aria_current(), Some(AriaCurrent::Page)); + } + #[test] fn states_and_actions() { let root = view(0, ViewKind::View, Accessibility::default()); diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs index 3604f1a991..18624eb62b 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/app.rs @@ -383,6 +383,7 @@ impl AppView { trace: options.trace, network: Some(nsh_engine::net::network()), websocket: None, + refuse_connections: None, filesystem: None, chunk_loader: None, assets: Some(Arc::new(AssetReader(options.asset_base.clone()))), @@ -986,6 +987,7 @@ fn initial_environment() -> Environment { Os::Linux }, idiom: Idiom::Desktop, + file_requests: true, ..Environment::default() } } diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/canvas.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/canvas.rs index 988574a4a2..6250515594 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/canvas.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/canvas.rs @@ -140,6 +140,25 @@ impl CanvasState { self.damage.set(None); } + /// The version a worker should rasterize now, if none is: one at a + /// time, as drawings that change on every frame would queue a job a + /// frame, the workers falling behind and showing frames ever older. + fn ask(&self) -> Option { + if self.inflight.get().is_some() { + return None; + } + self.inflight.set(Some(self.version)); + Some(self.version) + } + + /// A worker's rasterization of `version` is done: the next can be + /// asked for. + fn landed(&self, version: u64) { + if self.inflight.get() == Some(version) { + self.inflight.set(None); + } + } + /// The value of animated value slot `k`, as damage scans read it. fn slot(args: &[Option], k: u16) -> f32 { args.get(k as usize) @@ -410,8 +429,8 @@ pub(crate) fn draw(cx: &mut DrawCx<'_>, state: &ViewState) { if fresh && outdated { // A new drawing: a worker rasterizes it while the last one shows // (a frame or two); damage is worked out again when it is done. - if canvas.inflight.get() != Some(canvas.version) { - canvas.inflight.set(Some(canvas.version)); + // The latest is asked for when the one in flight lands. + if let Some(version) = canvas.ask() { spawn(Job { surface: SURFACE.get(), id: state.id, @@ -422,7 +441,7 @@ pub(crate) fn draw(cx: &mut DrawCx<'_>, state: &ViewState) { key, region, frac, - version: canvas.version, + version, opacity: cx.opacity, }); } @@ -648,15 +667,16 @@ fn deliver(surface: u64, id: nsh_core::NodeId, recorded: Recorded) { let Some(c) = state.canvas.as_mut() else { return; }; - if c.inflight.get() == Some(recorded.version) { - c.inflight.set(None); - } + c.landed(recorded.version); let newer = c .cache .get_mut() .as_ref() .is_none_or(|r| r.version < recorded.version); if !newer { + // Drawn again to ask for the latest, if it is not this. + drop(state); + view.changed(); return; } // Values that moved while it was made: their areas are made again. @@ -3787,4 +3807,22 @@ mod tests { assert_eq!(at(&p, 0, 0)[0], 255); assert_eq!(p.pixel(0, 0).unwrap().red(), 128); } + + #[test] + fn a_canvas_changing_on_every_frame_keeps_one_job_in_flight() { + let mut canvas = CanvasState::default(); + assert_eq!(canvas.ask(), Some(0)); + // Changed on every frame while a worker makes it: nothing more is + // queued behind it. + for _ in 0..10 { + canvas.stale(); + assert_eq!(canvas.ask(), None); + } + // Once it lands, the latest is asked for. + canvas.landed(0); + assert_eq!(canvas.ask(), Some(10)); + // A rasterization that lands late frees nothing. + canvas.landed(3); + assert_eq!(canvas.ask(), None); + } } diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs index 2a29739611..d04338aca0 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/image.rs @@ -3,24 +3,27 @@ //! GPUI images, drawing with the resize mode, corner radii, tint, blur, //! filters, opacity and placeholder. //! -//! Loads run on a small pool of worker threads: read the file or download -//! (ureq, following redirects one checked hop at a time; `onProgress` at -//! most every 100ms), decode with the `image` crate (PNG, JPEG, GIF, WebP, -//! BMP, ICO, TIFF; EXIF orientation applied; every frame of animated GIF -//! and WebP) into a GPUI [`RenderImage`] (straight BGRA, one frame per -//! animation frame) and hand it to the main thread with [`rt::invoke`]. -//! SVG images (sniffed from their bytes) are drawn by [`nsh_core::svg`] at -//! their intrinsic size × [`nsh_core::svg::OVERSAMPLE`] (at most -//! [`nsh_core::svg::MAX_OVERSAMPLED_SIDE`] a side) and then handled as -//! bitmaps; their size is their intrinsic size in points. Canvases draw -//! bitmaps only ([`load_bitmap`]). -//! Decoded images are kept in a main-thread cache keyed by the resolved -//! location (so `asset:/` URIs of surfaces with different asset bases -//! never mix). Host-provided assets are keyed by their surface/load binding -//! and opaque URI. The cache is bounded by bytes and evicts the least recently used; -//! concurrent requests for one location share one load. Images decode at -//! their full size, up to [`MAX_DIMENSION`] pixels a side (larger ones are -//! scaled down on the worker: GPU textures have a size limit). +//! Loads go through the core's shared loader and decoded images +//! ([`nsh_core::image::Images`], `docs/dev/images.md`): one read per source +//! in flight (redirects checked against the app's access policy), reads +//! and decodes on their own pools, the encoded bytes and failures +//! remembered, and pictures kept in the process's image budget, leased by +//! the views that show them. An image decodes at the size its view shows +//! it ([`Want`]: the size bucket covering its box in device pixels, never +//! above its natural size nor [`MAX_DIMENSION`] a side) with the `image` +//! crate (PNG, JPEG, GIF, WebP, BMP, ICO, TIFF; EXIF orientation applied; +//! every frame of animated GIF and WebP), shrunk as it decodes, into a GPUI +//! [`RenderImage`] (straight BGRA, one frame per animation frame); a view +//! that grows past its bucket loads the next one. SVG images (sniffed from +//! their bytes) are drawn by [`nsh_core::svg`] at the bucket's size and +//! then handled as bitmaps; their size is their intrinsic size in points. +//! Canvases draw bitmaps only ([`load_bitmap`]). +//! +//! A decoded entry's picture on the main thread ([`Image`]) counts what it +//! adds in its entry ([`nsh_core::image::MemoryCache::add_cost`]): its +//! atlas copy once drawn and its variants (their pixels and atlas copies). +//! Evicting the entry drops the picture, and its textures leave the +//! atlases once no view shows them. //! //! GPUI draws an image as a sprite of its texture atlas, scaled bilinearly, //! with rounded corners, inside rectangular content masks, and nothing @@ -59,21 +62,26 @@ //! iOS). use std::{ + any::Any, cell::{Cell, RefCell}, - io::Read, - path::PathBuf, rc::{Rc, Weak}, - sync::{Arc, OnceLock}, - time::{Duration, Instant}, + sync::{ + Arc, LazyLock, OnceLock, + atomic::{AtomicU64, Ordering}, + }, + time::Instant, }; use gpui::{Corners, RenderImage}; +pub use nsh_core::image::loader::AssetBase; use nsh_core::{ Color, Command, EventKind, EventPayload, ImageData, ImageOptions, ImageSource, NodeId, ObjectPosition, PayloadValue, ResizeMode, - access::Access, - geometry::{DevicePixels, Points, Size}, - image::{CacheHeaders, DiskCache, Fetched}, + geometry::{DevicePixels, Points, Size, size}, + image::{ + DiskCache, Frames, Images, Key, Lease, Natural, Request, Target, Want, + loader::{Context, Priority, ReadAsset, Ticket, loader}, + }, }; use rustc_hash::FxHashMap; @@ -84,26 +92,14 @@ use crate::{ view::{NshView, ViewState}, }; -/// Decoded images kept in memory, in bytes (4 per pixel). -const CACHE_BYTES: usize = 128 << 20; - -/// The largest download or file read. -const MAX_BYTES: u64 = 64 << 20; - -/// Loader threads: reads, downloads, decodes and variants run there. +/// Threads that make variants. const WORKERS: usize = 4; -/// Progress reports at most this often (a download reports its end too). -const PROGRESS_INTERVAL: Duration = Duration::from_millis(100); - /// The longest side of a decoded image, in pixels: larger images are -/// scaled down on the worker (the texture size every GPU takes, and one +/// scaled down as they decode (the texture size every GPU takes, and one /// this size already takes 256 MB). const MAX_DIMENSION: u32 = 8192; -/// Redirects followed at most (as `fetch`). -const MAX_REDIRECTS: usize = 20; - /// Variants a picture keeps (more while recent ones are still drawn). const VARIANTS: usize = 4; @@ -114,39 +110,6 @@ const ALPHA_STEPS: u8 = 16; /// blurs scale the variant down first (a blur hides the lost detail). const MAX_BLUR: f32 = 16.0; -/// Where bundled (`asset:/…`) files are. -#[derive(Debug, Clone)] -pub enum AssetBase { - /// Served over HTTP (the dev server): `http://host:port/assets/`. - Http(String), - /// A directory on disk. - Directory(PathBuf), - /// Directories on disk, looked in in order: a file is the first - /// directory's that has it (the app's own files, then an embedding - /// app's, say). - Directories(Vec), -} - -impl AssetBase { - /// The file of `path` (relative, `/`-separated) on disk: in the first - /// of [`AssetBase::Directories`] that has it, else in the first (where - /// reading it fails, naming that path). `None` over HTTP. - pub(crate) fn file(&self, path: &str) -> Option { - match self { - AssetBase::Http(_) => None, - AssetBase::Directory(dir) => Some(dir.join(path)), - AssetBase::Directories(dirs) => { - let mut files = dirs.iter().map(|dir| dir.join(path)); - let first = files.next()?; - if first.exists() { - return Some(first); - } - Some(files.find(|file| file.exists()).unwrap_or(first)) - } - } - } -} - /// Reads `asset:/…` files for the engine (fonts), on the engine thread. pub(crate) struct AssetReader(pub(crate) Option); @@ -155,16 +118,21 @@ impl nsh_engine::Assets for AssetReader { if !uri.starts_with("asset:/") { return Err(format!("not an asset URI: {uri}")); } - match resolve(uri, self.0.as_ref()) { - Some(Location::File(path)) => { - std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display())) - } + let context = Context { + asset_base: self.0.clone(), + ..Context::default() + }; + match context.resolve(uri) { Some(location) => { - read(&location, &Access::default(), None, &mut |_, _| {}).map(|b| b.to_vec()) + nsh_core::image::loader::read_now(&location, &context).map(|b| b.to_vec()) } None => Err(format!("{uri}: no asset location configured")), } } + + fn base(&self) -> Option { + self.0.clone() + } } /// The default bundled location, or the reader bound to an engine load. @@ -173,35 +141,12 @@ impl nsh_engine::Assets for AssetReader { pub(crate) enum AssetSource { #[default] Base, - Engine(Option>), -} - -/// One worker's original source, retained until its read finishes. -enum LoadSource { - Location(Location), - Asset { - uri: String, - reader: Arc, - }, + Engine(Option), } -impl LoadSource { - fn read( - &self, - access: &Access, - cache: Option<&DiskCache>, - progress: &mut dyn FnMut(u64, u64), - ) -> Result { - match self { - Self::Location(location) => read(location, access, cache, progress), - Self::Asset { uri, reader } => { - let bytes = reader.read(uri)?; - if bytes.len() as u64 > MAX_BYTES { - return Err(format!("{uri}: image exceeds the encoded-byte limit")); - } - Ok(bytes.into()) - } - } +impl AssetSource { + pub(crate) fn engine(assets: Option>) -> Self { + Self::Engine(assets.map(|assets| ReadAsset::new(move |uri: &str| assets.read(uri)))) } } @@ -210,257 +155,29 @@ pub(crate) fn set_disk_cache(surface: &Surface, cache: Option) { *surface.image_cache.borrow_mut() = cache; } -/// The disk cache a load of `uri` goes through: the surface's, for remote -/// images (`asset:/` URIs, served by the dev server during development, -/// change as the app does: never kept). -fn disk_cache(surface: &Surface, uri: &str) -> Option { - let remote = uri.starts_with("http://") || uri.starts_with("https://"); - remote - .then(|| surface.image_cache.borrow().clone()) - .flatten() -} - /// Where this surface's images resolve `asset:/` URIs. pub(crate) fn set_asset_base(surface: &Rc, base: Option) { *surface.asset_base.borrow_mut() = base; } -/// Where an image's bytes come from. -#[derive(Debug, Clone, PartialEq)] -enum Location { - /// `http(s)://`. - Url(String), - /// A file on disk. - File(PathBuf), - /// A `data:` URI (decoded on the worker): `Image.getSize`'s; a view's - /// source arrives decoded ([`Location::Memory`]). - Data(String), - /// Bytes in memory (a decoded `data:` URI, a blob), by the source's - /// URI. - Memory(String, ImageData), -} - -impl Location { - /// The cache key: the same location is the same image, whatever URI - /// named it. - fn key(&self) -> String { - match self { - Location::Url(url) => url.clone(), - Location::File(path) => format!("file://{}", path.display()), - Location::Data(uri) => uri.clone(), - // App-written URIs must not address bytes carried by another - // surface, even if they spell the core's internal image key. - Location::Memory(uri, _) => format!("inline:{uri}"), - } - } -} - -/// Resolves `uri` (`asset:/…` against `base`, `http(s)://`, `file:`, an -/// absolute path, `data:`). -fn resolve(uri: &str, base: Option<&AssetBase>) -> Option { - if let Some(path) = uri.strip_prefix("asset:/") { - let path = path.trim_start_matches('/'); - return match base? { - AssetBase::Http(base) => { - let sep = if base.ends_with('/') { "" } else { "/" }; - Some(Location::Url(format!("{base}{sep}{path}"))) - } - dirs => dirs.file(path).map(Location::File), - }; - } - if uri.starts_with("http://") || uri.starts_with("https://") { - return Some(Location::Url(uri.to_owned())); - } - if uri.starts_with("data:") { - return Some(Location::Data(uri.to_owned())); - } - if let Some(rest) = uri.strip_prefix("file:") { - return file_uri(rest).map(Location::File); - } - absolute_path(uri).then(|| Location::File(uri.into())) -} - -/// True for an absolute path of this system: `/…`; on Windows `C:\…`, -/// `C:/…` or a UNC path. -fn absolute_path(path: &str) -> bool { - if cfg!(windows) { - let b = path.as_bytes(); - let drive = b.len() >= 3 - && b[0].is_ascii_alphabetic() - && b[1] == b':' - && matches!(b[2], b'\\' | b'/'); - drive || path.starts_with("\\\\") || path.starts_with("//") - } else { - path.starts_with('/') - } -} - -/// The path of a `file:` URI (what follows `file:`): `///home/a%20b.png` -/// is `/home/a b.png`; on Windows `///C:/a.png` is `C:\a.png` and -/// `//server/share/a.png` is `\\server\share\a.png`. -fn file_uri(rest: &str) -> Option { - let decoded = String::from_utf8(percent_decode(rest.as_bytes())).ok()?; - let path = match decoded.strip_prefix("//") { - // `file:///…` (an empty host) or `file://localhost/…`. - Some(after) => match after.split_once('/') { - Some(("" | "localhost", path)) => format!("/{path}"), - Some((host, path)) if cfg!(windows) => format!("//{host}/{path}"), - _ => return None, - }, - // `file:/…`. - None => decoded, +/// What a load of `uri` for `surface` goes through: its asset base, its +/// access policy and, for remote images, its disk cache (`asset:/` URIs, +/// served by the dev server during development, change as the app does: +/// never kept). +fn context(surface: &Surface, uri: &str) -> Context { + let remote = uri.starts_with("http://") || uri.starts_with("https://"); + let (asset_base, read_asset) = match &*surface.image_assets.borrow() { + AssetSource::Base => (surface.asset_base.borrow().clone(), None), + AssetSource::Engine(read) => (None, read.clone()), }; - if cfg!(windows) { - // `/C:/…` is `C:\…`; `//host/…` stays a UNC path. - let path = match path.strip_prefix('/') { - Some(p) if !p.starts_with('/') => p.to_owned(), - _ => path, - }; - let path = path.replace('/', "\\"); - absolute_path(&path).then(|| path.into()) - } else { - absolute_path(&path).then(|| path.into()) - } -} - -/// `%xx` escapes decoded; malformed ones stay as they are. -fn percent_decode(bytes: &[u8]) -> Vec { - let hex = |b: u8| (b as char).to_digit(16); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - match ( - bytes[i], - bytes.get(i + 1).and_then(|&b| hex(b)), - bytes.get(i + 2).and_then(|&b| hex(b)), - ) { - (b'%', Some(h), Some(l)) => { - out.push((h * 16 + l) as u8); - i += 3; - } - (b, _, _) => { - out.push(b); - i += 1; - } - } - } - out -} - -/// The bytes of a `data:` URI (base64 or percent-encoded). -fn data_uri(uri: &str) -> Result, String> { - nsh_core::image::decode_data_uri(uri).map(|(_, bytes)| bytes) -} - -/// The HTTP agent all downloads share. It never follows redirects: each hop -/// is checked against the app's capabilities before it is requested. -fn agent() -> &'static ureq::Agent { - static AGENT: OnceLock = OnceLock::new(); - AGENT.get_or_init(|| { - ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(60))) - .http_status_as_error(false) - .max_redirects(0) - .max_redirects_will_error(false) - .build() - .into() - }) -} - -/// GETs `url` with `headers`, following redirects one hop at a time: a hop -/// the app is not granted is never requested. -fn get( - url: &str, - access: &Access, - headers: &[(&'static str, String)], -) -> Result, String> { - let mut url = url.to_owned(); - for _ in 0..=MAX_REDIRECTS { - let mut request = agent().get(&url); - for (name, value) in headers { - request = request.header(*name, value); - } - let response = request.call().map_err(|e| e.to_string())?; - let status = response.status().as_u16(); - if !matches!(status, 301 | 302 | 303 | 307 | 308) { - return Ok(response); - } - let location = response - .headers() - .get("location") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| format!("{url}: redirect without a Location"))?; - url = access.media_redirect(&url, location)?; - } - Err("too many redirects".to_owned()) -} - -/// GETs `url` with `headers` (a revalidation's conditions), following -/// redirects one hop at a time (a hop the app is not granted is never -/// requested), and reads the body as it arrives (`progress(loaded, -/// total)`, total 0: unknown). Every status is a response: the cache -/// decides what a `304` or a `5xx` means. -fn download( - url: &str, - access: &Access, - headers: &[(&'static str, String)], - progress: &mut dyn FnMut(u64, u64), -) -> Result { - let mut response = get(url, access, headers)?; - let status = response.status().as_u16(); - let cache = CacheHeaders::from_lookup(|name| { - let values: Vec<&str> = response - .headers() - .get_all(name) - .iter() - .filter_map(|v| v.to_str().ok()) - .collect(); - (!values.is_empty()).then(|| values.join(", ")) - }); - if status >= 300 { - return Ok(Fetched::new(status, cache, Vec::new())); - } - let total = response.body().content_length().unwrap_or(0); - let mut reader = response.body_mut().with_config().limit(MAX_BYTES).reader(); - let mut bytes = Vec::with_capacity(total.min(MAX_BYTES) as usize); - let mut chunk = [0u8; 16 << 10]; - loop { - let n = reader.read(&mut chunk).map_err(|e| e.to_string())?; - if n == 0 { - break; - } - bytes.extend_from_slice(&chunk[..n]); - progress(bytes.len() as u64, total); - } - Ok(Fetched::new(status, cache, bytes)) -} - -/// Reads the bytes at `location` (blocking), calling `progress(loaded, -/// total)` (total 0: unknown) as a download goes; a download through -/// `cache` (the surface's disk cache, for `http(s)://` sources) when -/// there is one. -fn read( - location: &Location, - access: &Access, - cache: Option<&DiskCache>, - progress: &mut dyn FnMut(u64, u64), -) -> Result { - match location { - Location::File(path) => std::fs::read(path) - .map(ImageData::from) - .map_err(|e| format!("{}: {e}", path.display())), - Location::Data(uri) => data_uri(uri).map(ImageData::from), - Location::Memory(_, data) => Ok(data.clone()), - Location::Url(url) => match cache { - Some(cache) => cache.load(url, |headers| download(url, access, headers, progress)), - None => { - download(url, access, &[], progress).and_then(|response| match response.status { - 200..=299 => Ok(response.body.into()), - status => Err(format!("HTTP {status}")), - }) - } - } - .map_err(|e| format!("{url}: {e}")), + Context { + asset_base, + read_asset, + access: surface.access.borrow().clone(), + disk: remote + .then(|| surface.image_cache.borrow().clone()) + .flatten(), + transport: None, } } @@ -488,32 +205,46 @@ fn bgra_frame(rgba: image::RgbaImage) -> image::Frame { image::Frame::new(image::RgbaImage::from_raw(w, h, data).unwrap_or_default()) } -/// What a worker decoded (sent to the main thread). -struct Decoded { +/// What a decode made (any thread; the budget keeps it): the pixels of +/// every frame. +#[derive(Clone)] +struct Pixels { + /// Names its picture on the main thread ([`PICTURES`]). + id: u64, /// The image, every frame of an animation. image: Arc, /// Each frame's delay in ms (animations). - delays: Vec, + delays: Arc<[u32]>, /// Plays before it stops on its last frame; 0: forever. plays: u32, - /// The source's size in pixels (before any scaling down; an SVG's: - /// whole points). - size: (u32, u32), - /// An SVG's intrinsic size, unrounded: what it lays out at, whatever its - /// file name says. - points: Option>, + /// Its own size (an SVG's in points: what it lays out at and reports, + /// whatever its file name says), whatever size it was decoded at. + natural: Natural, } -impl Decoded { - fn new(frames: Vec, delays: Vec, plays: u32, size: (u32, u32)) -> Decoded { - Decoded { +impl Pixels { + fn new(frames: Vec, delays: Vec, plays: u32, natural: Natural) -> Pixels { + static NEXT: AtomicU64 = AtomicU64::new(0); + Pixels { + id: NEXT.fetch_add(1, Ordering::Relaxed), image: Arc::new(RenderImage::new(frames)), - delays, + delays: delays.into(), plays, - size, - points: None, + natural, } } + + /// Its size in pixels (as decoded). + fn size(&self) -> (u32, u32) { + let size = self.image.size(0); + (size.width.0.max(0) as u32, size.height.0.max(0) as u32) + } + + /// Bytes its pixels take (4 per pixel, every frame). + fn bytes(&self) -> usize { + let (w, h) = self.size(); + w as usize * h as usize * 4 * self.image.frame_count().max(1) + } } /// A frame's delay in ms: as browsers and iOS, delays under 20ms (often @@ -537,9 +268,43 @@ fn animated_format(bytes: &[u8]) -> Option { } } -/// Decodes the frames of a GIF or WebP; `None` when it is a still image -/// (or too large to animate: then it decodes as one). -fn animation(bytes: &[u8], format: image::ImageFormat) -> Result, String> { +/// The size an image of `size` pixels decodes at to have its longest side +/// `longest` pixels long (0: its own): never larger than it is, nor than +/// [`MAX_DIMENSION`] a side. +fn scaled(size: (u32, u32), longest: u32) -> (u32, u32) { + let (w, h) = size; + let side = w.max(h); + let limit = match longest { + 0 => MAX_DIMENSION, + l => l.min(MAX_DIMENSION), + }; + if side <= limit { + return size; + } + let f = limit as f64 / side as f64; + ( + ((w as f64 * f).round() as u32).max(1), + ((h as f64 * f).round() as u32).max(1), + ) +} + +/// `rgba` scaled to [`scaled`]'s size. +fn shrunk(rgba: image::RgbaImage, longest: u32) -> image::RgbaImage { + let size = rgba.dimensions(); + match scaled(size, longest) { + s if s == size => rgba, + (w, h) => image::imageops::thumbnail(&rgba, w, h), + } +} + +/// Decodes the frames of a GIF or WebP, its longest side at most `longest` +/// pixels (0: its own); `None` when it is a still image (or too large to +/// animate: then it decodes as one). +fn animation( + bytes: &[u8], + format: image::ImageFormat, + longest: u32, +) -> Result, String> { use image::{AnimationDecoder, metadata::LoopCount}; let cursor = std::io::Cursor::new(bytes); let error = |e: image::ImageError| e.to_string(); @@ -566,6 +331,7 @@ fn animation(bytes: &[u8], format: image::ImageFormat) -> Result LoopCount::Finite(n) => n.get(), }; let (mut buffers, mut delays, mut total) = (Vec::new(), Vec::new(), 0); + let mut natural = None; for frame in frames.take(MAX_FRAMES) { let frame = match frame { Ok(frame) => frame, @@ -578,6 +344,9 @@ fn animation(bytes: &[u8], format: image::ImageFormat) -> Result }; let delay = frame_delay(frame.delay()); let buffer = frame.into_buffer(); + let (w, h) = buffer.dimensions(); + natural.get_or_insert(Natural::Pixels(w, h)); + let buffer = shrunk(buffer, longest); total += buffer.as_raw().len(); if total > MAX_ANIMATION_BYTES { log::debug!("image: animation over {MAX_ANIMATION_BYTES} bytes: its first frame shows"); @@ -591,35 +360,20 @@ fn animation(bytes: &[u8], format: image::ImageFormat) -> Result buffers.push(buffer); delays.push(delay); } - let Some(first) = buffers.first() else { + let Some(natural) = natural.filter(|_| !buffers.is_empty()) else { return Err("no frames".into()); }; - let size = first.dimensions(); if buffers.len() == 1 { delays.clear(); } let frames = buffers.into_iter().map(bgra_frame).collect(); - Ok(Some(Decoded::new(frames, delays, plays, size))) -} - -/// The size an image of `size` pixels decodes at: its own, or scaled -/// down to [`MAX_DIMENSION`] a side. -fn bounded(size: (u32, u32)) -> (u32, u32) { - let (w, h) = size; - let longest = w.max(h); - if longest <= MAX_DIMENSION { - return size; - } - let f = MAX_DIMENSION as f64 / longest as f64; - ( - ((w as f64 * f).round() as u32).max(1), - ((h as f64 * f).round() as u32).max(1), - ) + Ok(Some(Pixels::new(frames, delays, plays, natural))) } /// Decodes a still image (any format the `image` crate reads), turned -/// upright as its EXIF orientation says. -fn decode_still(bytes: &[u8]) -> Result { +/// upright as its EXIF orientation says, its longest side at most +/// `longest` pixels (0: its own). +fn decode_still(bytes: &[u8], longest: u32) -> Result { use image::ImageDecoder; let error = |e: image::ImageError| e.to_string(); let reader = image::ImageReader::new(std::io::Cursor::new(bytes)) @@ -635,52 +389,92 @@ fn decode_still(bytes: &[u8]) -> Result { if size.0 == 0 || size.1 == 0 { return Err("empty image".into()); } - let image = match bounded(size) { - b if b == size => image, - (w, h) => { - log::debug!("image: {}x{} scaled down to {w}x{h}", size.0, size.1); - image.resize(w, h, image::imageops::FilterType::Triangle) - } + // Shrunk as decoded (a JPEG's RGB), before it takes 4 bytes a pixel. + let image = match scaled(size, longest) { + s if s == size => image, + (w, h) => image.thumbnail(w, h), }; - Ok(Decoded::new( + Ok(Pixels::new( vec![bgra_frame(image.into_rgba8())], Vec::new(), 0, - size, + Natural::Pixels(size.0, size.1), )) } -/// Draws an SVG oversampled (premultiplied RGBA) into one straight BGRA -/// frame, which the variants then scale down as they do bitmaps. -fn decode_svg(bytes: &[u8]) -> Result { - let raster = nsh_core::svg::rasterize_oversampled(bytes).map_err(|e| e.to_string())?; +/// The natural size the header of `bytes` tells, turned as its EXIF +/// orientation says (a photo taken sideways reports its upright size, as +/// it is shown). +fn upright(bytes: &[u8]) -> Option { + use image::{ImageDecoder, metadata::Orientation}; + let natural = Natural::of(bytes)?; + let Natural::Pixels(w, h) = natural else { + return Some(natural); + }; + let orientation = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok() + .and_then(|reader| reader.into_decoder().ok()) + .and_then(|mut decoder| decoder.orientation().ok()); + let turned = matches!( + orientation, + Some( + Orientation::Rotate90 + | Orientation::Rotate270 + | Orientation::Rotate90FlipH + | Orientation::Rotate270FlipH + ) + ); + Some(if turned { + Natural::Pixels(h, w) + } else { + natural + }) +} + +/// Draws an SVG, its longest side `longest` pixels (premultiplied RGBA), +/// into one straight BGRA frame. +fn decode_svg(bytes: &[u8], longest: u32) -> Result { + let raster = nsh_core::svg::rasterize(bytes, longest.max(1)).map_err(|e| e.to_string())?; let mut data = raster.pixels; unpremultiply(&mut data); swap_red_blue(&mut data); let frame = image::RgbaImage::from_raw(raster.width, raster.height, data) .ok_or("SVG raster of the wrong size")?; - let reported = nsh_core::svg::reported_size(raster.size); - let size = (reported.width.0 as u32, reported.height.0 as u32); - Ok(Decoded { - points: Some(raster.size), - ..Decoded::new(vec![image::Frame::new(frame)], Vec::new(), 0, size) - }) + Ok(Pixels::new( + vec![image::Frame::new(frame)], + Vec::new(), + 0, + Natural::Vector(raster.size), + )) } -/// Decodes an encoded image (a worker): every frame of animations. -fn decode(bytes: impl AsRef<[u8]>) -> Result { - let bytes = bytes.as_ref(); +/// Decodes an encoded image at `target` (a decoding thread): every frame +/// of animations when it asks. +fn decode(bytes: &[u8], target: &Target) -> Result { + let longest = target.longest; if nsh_core::svg::sniff(bytes) { - return decode_svg(bytes); + return decode_svg(bytes, longest); } - if let Some(format) = animated_format(bytes) { - match animation(bytes, format) { - Ok(Some(decoded)) => return Ok(decoded), + if target.frames == Frames::All + && let Some(format) = animated_format(bytes) + { + match animation(bytes, format, longest) { + Ok(Some(pixels)) => return Ok(pixels), Ok(None) => {} Err(e) => log::debug!("image: not decoded as an animation: {e}"), } } - decode_still(bytes) + decode_still(bytes, longest) +} + +/// The size `onLoad` and `Image.getSize` report: pixels, or an SVG's whole +/// points. +fn reported_size(natural: Natural) -> Size { + match natural { + Natural::Vector(points) => nsh_core::svg::reported_size(points), + Natural::Pixels(w, h) => size(DevicePixels(w as i32), DevicePixels(h as i32)), + } } /// Queues `job` on the worker pool (started on first use). @@ -707,33 +501,6 @@ fn spawn(job: impl FnOnce() + Send + 'static) { let _ = tx.send(Box::new(job)); } -/// Loads `source` on a worker and hands the result to the main thread. -fn work( - key: String, - source: LoadSource, - access: Arc, - cache: Option, - progress: bool, -) { - let mut last: Option = None; - let mut report = |loaded: u64, total: u64| { - let now = Instant::now(); - let done = total > 0 && loaded >= total; - if !progress || !(done || last.is_none_or(|t| now - t >= PROGRESS_INTERVAL)) { - return; - } - last = Some(now); - let key = key.clone(); - rt::invoke(move || { - LOADER.with(|l| l.progress(&key, loaded, total)); - }); - }; - let result = source - .read(&access, cache.as_ref(), &mut report) - .and_then(decode); - rt::invoke(move || finish(key, result.map(Image::shared))); -} - thread_local! { /// Textures dropped since images were last drawn: they leave GPUI's /// atlases then ([`release_textures`]). @@ -760,6 +527,13 @@ impl Texture { fn frames(&self) -> usize { self.0.frame_count() } + + /// Bytes its pixels take (4 per pixel, every frame). + fn bytes(&self) -> usize { + let size = self.0.size(0); + let (w, h) = (size.width.0.max(0) as usize, size.height.0.max(0) as usize); + w * h * 4 * self.frames().max(1) + } } impl Drop for Texture { @@ -1061,42 +835,31 @@ struct Variant { used: Cell, } -/// A decoded image on the main thread (shared by the cache and the views -/// showing it). +/// A decoded image on the main thread (shared by its budget entry and the +/// views showing it). pub(crate) struct Image { /// The image, every frame of an animation. texture: Rc, /// Its size in pixels (as decoded). pixels: (u32, u32), - /// The source's size in pixels (an SVG's: whole points). - size: (u32, u32), - /// An SVG's intrinsic size, unrounded. - points: Option>, + /// Its own size (an SVG's in points). + natural: Natural, /// Each frame's delay in ms (animations). - delays: Rc<[u32]>, + delays: Arc<[u32]>, /// Plays before it stops on its last frame; 0: forever. plays: u32, variants: RefCell>, + /// Its entry in the budget, which its atlas copy and its variants count + /// in. + key: Key, + /// Its atlas copy is counted (it was drawn). + drawn: Cell, } /// A decoded image, shared. type Picture = Rc; impl Image { - /// What a worker decoded, shared. - fn shared(decoded: Decoded) -> Picture { - let size = decoded.image.size(0); - Rc::new(Image { - texture: Rc::new(Texture(decoded.image)), - pixels: (size.width.0.max(0) as u32, size.height.0.max(0) as u32), - size: decoded.size, - points: decoded.points, - delays: decoded.delays.into(), - plays: decoded.plays, - variants: RefCell::default(), - }) - } - /// Bytes its pixels take (4 per pixel, every frame). fn bytes(&self) -> usize { let (w, h) = self.pixels; @@ -1107,6 +870,324 @@ impl Image { fn animated(&self) -> bool { self.delays.len() > 1 } + + /// Adds `delta` bytes to what its entry costs (an atlas copy or a + /// variant made, or dropped). + fn count(&self, delta: isize) { + images().memory().add_cost(&self.key, delta); + } +} + +thread_local! { + /// The main-thread pictures of the decoded entries, by [`Pixels::id`]: + /// kept until the entry is evicted, then dropped (their textures leave + /// the atlases once the views showing them let go too). + static PICTURES: RefCell> = RefCell::default(); +} + +/// The main-thread picture of `pixels`, decoded as `key`'s entry. +fn picture(pixels: &Pixels, key: Key) -> Picture { + if let Some(picture) = PICTURES.with_borrow(|m| m.get(&pixels.id).cloned()) { + return picture; + } + let picture = Rc::new(Image { + texture: Rc::new(Texture(pixels.image.clone())), + pixels: pixels.size(), + natural: pixels.natural, + delays: pixels.delays.clone(), + plays: pixels.plays, + variants: RefCell::default(), + key: key.clone(), + drawn: Cell::new(false), + }); + // An entry evicted already would never drop it: it is not kept. + if images().memory().contains(&key) { + PICTURES.with_borrow_mut(|m| m.insert(pixels.id, picture.clone())); + } + picture +} + +/// The process's decoded images, in the core's budget. +fn images() -> &'static Images { + static IMAGES: LazyLock> = LazyLock::new(|| { + Images::with_on_evict( + nsh_core::image::budget(), + |bytes: &ImageData, target: &Target| { + let pixels = decode(bytes, target)?; + let cost = pixels.bytes(); + Ok((pixels.clone(), pixels.natural, cost)) + }, + // Its picture goes with it (and with that its textures, once + // no view shows them). + |pixels: &Pixels| { + let id = pixels.id; + rt::invoke(move || { + PICTURES.with_borrow_mut(|m| m.remove(&id)); + }); + }, + ) + }); + &IMAGES +} + +/// Called with a load's picture (and the lease that keeps it) or error. +type Done = Box)>; + +/// Called with a download's bytes so far and its size (0: unknown). +type Progress = Box; + +/// Main-thread callbacks by id. +type Waiting = RefCell>; + +thread_local! { + /// The main thread's side of the loads running: their callbacks + /// (`Box`) and progress listeners, by id. + static WAITING: Waiting> = RefCell::default(); + static PROGRESS: Waiting> = RefCell::default(); + static NEXT: Cell = const { Cell::new(0) }; +} + +fn next_id() -> u64 { + NEXT.with(|n| { + n.set(n.get() + 1); + n.get() + }) +} + +/// Keeps `f` on the main thread as callback `id`; the function returned +/// runs it there from any thread ([`rt::invoke`], also from the main +/// thread), unless it was forgotten ([`Load`]'s drop). +fn on_main(f: impl FnOnce(T) + 'static) -> (u64, impl FnOnce(T) + Send) { + let id = next_id(); + let f: Box = Box::new(f); + WAITING.with_borrow_mut(|w| w.insert(id, Box::new(f) as Box)); + let run = move |value: T| { + rt::invoke(move || { + let f = WAITING.with_borrow_mut(|w| w.remove(&id)); + if let Some(f) = f.and_then(|f| f.downcast::>().ok()) { + f(value); + } + }); + }; + (id, run) +} + +/// A load running for a view: dropping it gives up (and cancels the load +/// when nobody else waits for it). +pub(crate) struct Load { + ticket: Option, + /// Its callback and progress listener. + ids: (u64, u64), +} + +impl Load { + /// Lets it run to its end, callback included. + fn detach(mut self) { + if let Some(ticket) = self.ticket.take() { + ticket.detach(); + } + } +} + +impl Drop for Load { + fn drop(&mut self) { + if self.ticket.is_some() { + WAITING.with_borrow_mut(|w| w.remove(&self.ids.0)); + PROGRESS.with_borrow_mut(|p| p.remove(&self.ids.1)); + } + } +} + +/// Loads `source` at `want` for `surface`; `done` runs on the main thread. +/// Returns the load while it runs, `None` when `done` already ran (in +/// memory, or a source that cannot load). +/// +/// A cached picture is handed over before this returns: callers must not +/// hold a borrow of view state that `done` takes. +fn fetch( + surface: &Surface, + source: &ImageSource, + want: Want, + frames: Frames, + priority: Priority, + progress: Option, + done: Done, +) -> Option { + let surface_key = surface.key(); + let generation = surface.load_generation(); + let current = move || { + Surface::by_key(surface_key).is_some_and(|surface| surface.load_generation() == generation) + }; + let done: Done = Box::new(move |result| { + if current() { + done(result); + } + }); + let progress: Option = progress.map(|progress| { + Box::new(move |loaded, total| { + if current() { + progress(loaded, total); + } + }) as Progress + }); + let context = context(surface, &source.uri); + let Some(location) = context.locate(source) else { + done(Err(format!("unsupported image source `{}`", source.uri))); + return None; + }; + let listener = next_id(); + let progress = progress.map(|p| { + PROGRESS.with_borrow_mut(|m| m.insert(listener, Rc::from(p))); + Arc::new(move |loaded: u64, total: u64| { + rt::invoke(move || { + if let Some(p) = PROGRESS.with_borrow(|m| m.get(&listener).cloned()) { + p(loaded, total); + } + }); + }) as nsh_core::image::loader::Progress + }); + let request = Request { + location, + context, + declared: source.size, + want, + frames, + priority, + progress, + }; + // In memory: here and now, as the callers expect. + if let Some((decoded, lease)) = images().cached(&request) { + PROGRESS.with_borrow_mut(|p| p.remove(&listener)); + let key = images().key_of(&request, decoded.natural); + done(Ok((picture(&decoded.value, key), lease))); + return None; + } + let keyed = request.clone(); + let (id, done) = on_main(move |result: Result<(Pixels, Key, Lease), String>| { + PROGRESS.with_borrow_mut(|p| p.remove(&listener)); + done(result.map(|(pixels, key, lease)| (picture(&pixels, key), lease))); + }); + let ticket = images().load( + request, + Box::new(move |result| { + done(result.map(|(decoded, lease)| { + let key = images().key_of(&keyed, decoded.natural); + (decoded.value, key, lease) + })) + }), + ); + Some(Load { + ticket: Some(ticket), + ids: (id, listener), + }) +} + +/// Loads the bitmap `uri` (`https://…`, `asset:/…`, a file path, `data:`) +/// for a canvas off the main thread, at its natural size; `done` runs on +/// the main thread with the image (every frame of an animation: frame 0 +/// is the first). Canvases draw bitmaps: an SVG is an image that failed +/// to load. +/// +/// A cached image is handed over before this returns: callers must not +/// hold a borrow of view state that `done` takes. +pub(crate) fn load_bitmap( + surface: &Rc, + uri: &str, + done: impl FnOnce(Result, String>) + 'static, +) { + let name = uri.to_owned(); + let done = move |result: Result<(Picture, Lease), String>| { + done(result.and_then(|(p, _)| match p.natural { + Natural::Vector(_) => Err(format!( + "{name} is an SVG image: canvases draw bitmaps (PNG, JPEG, ...)" + )), + Natural::Pixels(..) => Ok(p.texture.clone()), + })); + }; + let source = ImageSource { + uri: uri.into(), + size: None, + scales: nsh_core::ImageScales::NONE, + data: None, + }; + // A canvas keeps the texture itself: the load runs to its end. + if let Some(load) = fetch( + surface, + &source, + Want::natural(1.0), + Frames::First, + Priority::Normal, + None, + Box::new(done), + ) { + load.detach(); + } +} + +/// `Command::ImageInfo` (`Image.getSize`, `Image.prefetch`): reads the +/// image's bytes (into the loader's memory and disk caches) and reports its +/// size in pixels (an SVG's: its intrinsic size in points), from its +/// header when it says, else by decoding it. +pub(crate) fn command(surface: &Rc, _id: NodeId, command: &Command) { + let Command::ImageInfo { id, uri } = command else { + return; + }; + let request = *id; + let weak = Rc::downgrade(surface); + let generation = surface.load_generation(); + let report = move |result: Result| { + let Some(surface) = weak.upgrade() else { + return; + }; + if surface.load_generation() != generation { + return; + } + let mut fields = vec![("id", PayloadValue::Number(request as f64))]; + match result { + Ok(natural) => { + let size = reported_size(natural); + fields.push(("width", PayloadValue::Number(size.width.0 as f64))); + fields.push(("height", PayloadValue::Number(size.height.0 as f64))); + } + Err(message) => fields.push(("error", PayloadValue::Str(message))), + } + surface.event( + nsh_core::ROOT_ID, + EventKind::ImageInfo, + EventPayload::Map(fields), + ); + }; + let context = context(surface, uri); + let Some(location) = context.resolve(uri) else { + report(Err(format!("unsupported image source `{uri}`"))); + return; + }; + let (_, report) = on_main(report); + let decode = Request { + location: location.clone(), + context: context.clone(), + declared: None, + want: Want::natural(1.0), + frames: Frames::First, + priority: Priority::Prefetch, + progress: None, + }; + // The header first; a decode only for what it cannot tell. + loader() + .read( + location, + &context, + Priority::Prefetch, + None, + Box::new(move |bytes| match bytes.map(|b| upright(&b)) { + Ok(Some(natural)) => report(Ok(natural)), + Ok(None) => images() + .load(decode, Box::new(move |r| report(r.map(|(d, _)| d.natural)))) + .detach(), + Err(e) => report(Err(e)), + }), + ) + .detach(); } /// The texture to draw `picture` with `look`: the variant if it is made, @@ -1226,8 +1307,11 @@ fn made_variant(job: u64, look: Look, made: Arc, surface: u64) { let Some(v) = variants.iter_mut().find(|v| v.look == look) else { return; }; + // Its pixels and their atlas copy count in the picture's entry. + let made = texture.bytes(); v.texture = Some(texture); - trim(&mut variants, monotonic()); + let freed = trim(&mut variants, monotonic()); + picture.count(2 * (made as isize - freed as isize)); } if let Some(surface) = Surface::by_key(surface) { surface.invalidate(); @@ -1236,8 +1320,9 @@ fn made_variant(job: u64, look: Look, made: Arc, surface: u64) { /// Drops the least recently drawn variants beyond [`VARIANTS`], keeping /// the ones being made and the ones drawn in the last second (which may -/// still be on screen). -fn trim(variants: &mut Vec, now: i64) { +/// still be on screen); returns the bytes of their pixels. +fn trim(variants: &mut Vec, now: i64) -> usize { + let mut freed = 0; while variants.len() > VARIANTS { let oldest = variants .iter() @@ -1247,291 +1332,13 @@ fn trim(variants: &mut Vec, now: i64) { .map(|(i, _)| i); match oldest { Some(i) => { - variants.swap_remove(i); + let gone = variants.swap_remove(i); + freed += gone.texture.map_or(0, |t| t.bytes()); } None => break, } } -} - -/// Called with a load's picture or error. -type Done = Box)>; - -/// Called with a download's bytes so far and its size (0: unknown). -type Progress = Box; - -/// Someone waiting for a load. -struct Waiter { - done: Done, - progress: Option, -} - -/// A cached picture. -struct Entry { - picture: Picture, - bytes: usize, - used: u64, -} - -/// The main thread's image cache and running loads. -#[derive(Default)] -struct Loader { - cache: RefCell>, - bytes: Cell, - clock: Cell, - inflight: RefCell>>, -} - -thread_local! { - static LOADER: Loader = Loader::default(); -} - -impl Loader { - fn tick(&self) -> u64 { - let now = self.clock.get() + 1; - self.clock.set(now); - now - } - - fn cached(&self, key: &str) -> Option { - let mut cache = self.cache.borrow_mut(); - let entry = cache.get_mut(key)?; - entry.used = self.tick(); - Some(entry.picture.clone()) - } - - fn insert(&self, key: String, picture: Picture) { - let bytes = picture.bytes(); - if bytes > CACHE_BYTES / 2 { - return; - } - let used = self.tick(); - let mut cache = self.cache.borrow_mut(); - let mut total = self.bytes.get() + bytes; - let entry = Entry { - picture, - bytes, - used, - }; - if let Some(old) = cache.insert(key, entry) { - total -= old.bytes; - } - // Rare, and the cache holds hundreds of entries at most: a scan for - // the oldest beats keeping an ordered index up to date on every hit. - while total > CACHE_BYTES { - let Some(oldest) = cache - .iter() - .min_by_key(|(_, e)| e.used) - .map(|(k, _)| k.clone()) - else { - break; - }; - if let Some(e) = cache.remove(&oldest) { - total -= e.bytes; - } - } - self.bytes.set(total); - } - - fn progress(&self, key: &str, loaded: u64, total: u64) { - if let Some(waiters) = self.inflight.borrow().get(key) { - for p in waiters.iter().filter_map(|w| w.progress.as_ref()) { - p(loaded, total); - } - } - } -} - -/// A load finished (main thread): caches it and calls the waiters. -fn finish(key: String, result: Result) { - let waiters = LOADER.with(|l| { - if let Ok(picture) = &result { - l.insert(key.clone(), picture.clone()); - } - l.inflight.borrow_mut().remove(&key).unwrap_or_default() - }); - for w in waiters { - (w.done)(result.clone()); - } -} - -fn source(surface: &Surface, uri: &str) -> Result<(String, LoadSource), String> { - if uri.starts_with("asset:/") - && let AssetSource::Engine(reader) = &*surface.image_assets.borrow() - { - let reader = reader - .clone() - .ok_or_else(|| format!("{uri}: no asset reader configured"))?; - // A load binding, rather than an Arc address: a stopped Host may be - // freed while the decoded picture still lives in the global cache. - let key = format!( - "host-asset:{}:{}:{uri}", - surface.key(), - surface.load_generation() - ); - return Ok(( - key, - LoadSource::Asset { - uri: uri.to_owned(), - reader, - }, - )); - } - let location = resolve(uri, surface.asset_base.borrow().as_ref()) - .ok_or_else(|| format!("unsupported image source `{uri}`"))?; - Ok((location.key(), LoadSource::Location(location))) -} - -/// Loads `uri` for `surface`; `done` runs on the main thread. Returns -/// whether it already ran (cached, or a source that cannot load). -fn fetch(surface: &Surface, uri: &str, progress: Option, done: Done) -> bool { - fetch_at( - surface, - source(surface, uri), - disk_cache(surface, uri), - progress, - done, - ) -} - -/// Loads `source` for `surface` as [`fetch`] does: from its bytes, when it -/// carries them (a `data:` URI, a blob). -fn fetch_source( - surface: &Surface, - source: &ImageSource, - progress: Option, - done: Done, -) -> bool { - match &source.data { - Some(data) => { - let location = Location::Memory(source.uri.to_string(), data.clone()); - fetch_at( - surface, - Ok((location.key(), LoadSource::Location(location))), - None, - progress, - done, - ) - } - None => fetch(surface, &source.uri, progress, done), - } -} - -/// Loads a resolved source for `surface`, as [`fetch`] does. Every path -/// keeps its callbacks bound to the engine load that requested it. -fn fetch_at( - surface: &Surface, - source: Result<(String, LoadSource), String>, - cache: Option, - progress: Option, - done: Done, -) -> bool { - let surface_key = surface.key(); - let generation = surface.load_generation(); - let current = move || { - Surface::by_key(surface_key).is_some_and(|surface| surface.load_generation() == generation) - }; - let done: Done = Box::new(move |result| { - if current() { - done(result); - } - }); - let progress: Option = progress.map(|progress| { - Box::new(move |loaded, total| { - if current() { - progress(loaded, total); - } - }) as Progress - }); - let (key, source) = match source { - Ok(source) => source, - Err(reason) => { - done(Err(reason)); - return true; - } - }; - if let Some(picture) = LOADER.with(|l| l.cached(&key)) { - done(Ok(picture)); - return true; - } - let listens = progress.is_some(); - let start = LOADER.with(|l| { - let mut inflight = l.inflight.borrow_mut(); - let waiters = inflight.entry(key.clone()).or_default(); - waiters.push(Waiter { done, progress }); - waiters.len() == 1 - }); - if start { - let access = surface.access.borrow().clone(); - spawn(move || work(key, source, access, cache, listens)); - } - false -} - -/// Loads `uri` (`https://…`, `asset:/…`, a file path, `data:`) off the -/// main thread, through the image cache; `done` runs on the main thread -/// with the image (every frame of an animation: frame 0 is the first) and -/// the source's size in pixels (an SVG's: in points). -/// -/// A cached image is handed over before this returns: callers must not -/// hold a borrow of view state that `done` takes. -pub(crate) fn load( - surface: &Rc, - uri: &str, - done: impl FnOnce(Result<(Rc, (u32, u32)), String>) + 'static, -) { - let done = move |result: Result| { - done(result.map(|p| (p.texture.clone(), p.size))); - }; - fetch(surface, uri, None, Box::new(done)); -} - -/// Loads the bitmap `uri` for a canvas, as [`load`] does: canvases draw -/// bitmaps, so an SVG is an image that failed to load. -pub(crate) fn load_bitmap( - surface: &Rc, - uri: &str, - done: impl FnOnce(Result, String>) + 'static, -) { - let source = uri.to_owned(); - let done = move |result: Result| { - done(result.and_then(|p| match p.points { - Some(_) => Err(format!( - "{source} is an SVG image: canvases draw bitmaps (PNG, JPEG, ...)" - )), - None => Ok(p.texture.clone()), - })); - }; - fetch(surface, uri, None, Box::new(done)); -} - -/// `Command::ImageInfo` (`Image.getSize`, `Image.prefetch`): loads the -/// image into the cache and reports its size in pixels (an SVG's: its -/// intrinsic size in points). -pub(crate) fn command(surface: &Rc, _id: NodeId, command: &Command) { - let Command::ImageInfo { id, uri } = command else { - return; - }; - let request = *id; - let weak = Rc::downgrade(surface); - load(surface, uri, move |result| { - let Some(surface) = weak.upgrade() else { - return; - }; - let mut fields = vec![("id", PayloadValue::Number(request as f64))]; - match result { - Ok((_, (w, h))) => { - fields.push(("width", PayloadValue::Number(w as f64))); - fields.push(("height", PayloadValue::Number(h as f64))); - } - Err(message) => fields.push(("error", PayloadValue::Str(message))), - } - surface.event( - nsh_core::ROOT_ID, - EventKind::ImageInfo, - EventPayload::Map(fields), - ); - }); + freed } /// A fade-in in progress. @@ -1549,10 +1356,17 @@ pub(crate) struct ImageState { source: Option, mode: ResizeMode, options: ImageOptions, - /// The source's image, once loaded. + /// The source's picture, once loaded, and the lease that keeps it. image: Option, - /// The placeholder's image, until the image shows. + lease: Lease, + /// The source's load running (a first one, or a larger size). + load: Option, + /// The placeholder's picture, until the image shows. placeholder: Option, + placeholder_lease: Lease, + placeholder_load: Option, + /// Its loads wait for the view's first frame. + deferred: bool, /// Bumped on every source change: older loads are stale. generation: u64, /// The image's opacity while it fades in. @@ -1630,7 +1444,7 @@ fn monotonic() -> i64 { /// An animation playing in a view. struct Playback { - delays: Rc<[u32]>, + delays: Arc<[u32]>, plays: u32, timeline: Timeline, /// A timer for the next frame is pending. @@ -1738,6 +1552,16 @@ pub(crate) fn create(view: &NshView) { view.state_mut().image = Some(ImageState::default()); } +/// The size `surface` shows an image in a box of `frame` at: the box in +/// device pixels, fitted by `mode`. +fn want(surface: &Surface, frame: Size, mode: ResizeMode) -> Want { + Want { + size: frame, + scale: surface.scale(), + mode, + } +} + /// An image view's source changed. pub(crate) fn set( surface: &Rc, @@ -1746,9 +1570,9 @@ pub(crate) fn set( mode: ResizeMode, options: &ImageOptions, ) { - let (generation, needs_image, events, id) = { + { let mut s = view.state_mut(); - let (events, id) = (s.events, s.id); + let id = s.id; let image = s.image.get_or_insert_with(ImageState::default); image.owner = (surface.key(), id); let same_source = image.source.as_ref() == source; @@ -1758,27 +1582,87 @@ pub(crate) fn set( if same_source && same_placeholder { drop(s); view.changed(); + // A new resize mode may show it larger. + resized(surface, view); return; } image.generation += 1; if !same_source { image.source = source.cloned(); image.image = None; + image.lease = Lease::none(); + image.load = None; image.fade = None; image.playback = None; } if !same_placeholder { image.placeholder = None; + image.placeholder_lease = Lease::none(); + image.placeholder_load = None; } - (image.generation, image.image.is_none(), events, id) - }; + } view.changed(); - let key = surface.key(); - // The image first: when it is cached, the placeholder is not needed. - if let Some(source) = source.filter(|_| needs_image) { + let laid_out = { + let frame = view.state().frame.size; + frame.width.0 > 0.0 || frame.height.0 > 0.0 + }; + if laid_out { + start(surface, view); + return; + } + // A new view's frame comes after its source in the same batch: its + // loads start once it has one (posted, so after the batch), so they + // decode at the size it shows them. + let (generation, owner) = { + let mut s = view.state_mut(); + let Some(image) = s.image.as_mut().filter(|i| !i.deferred) else { + return; + }; + image.deferred = true; + (image.generation, image.owner) + }; + rt::invoke(move || { + let (Some(surface), Some(view)) = (Surface::by_key(owner.0), owner_view(owner)) else { + return; + }; + let current = view.state_mut().image.as_mut().is_some_and(|i| { + i.deferred = false; + i.generation == generation + }); + if current { + start(&surface, &view); + } + }); +} + +/// Starts the loads `view` waits for: its source's, then its placeholder's +/// (not needed when the source is in memory). +fn start(surface: &Rc, view: &NshView) { + let (generation, events, owner, source, frame, mode) = { + let s = view.state(); + let (events, frame) = (s.events, s.frame.size); + let Some(image) = s.image.as_ref() else { + return; + }; + let source = image + .source + .clone() + .filter(|_| image.image.is_none() && image.load.is_none()); + ( + image.generation, + events, + image.owner, + source, + frame, + image.mode, + ) + }; + let id = owner.1; + let want = want(surface, frame, mode); + if let Some(source) = source { let progress = events.contains(EventKind::Progress).then(|| { Box::new(move |loaded: u64, total: u64| { - let Some(surface) = Surface::by_key(key) else { + let Some(surface) = Surface::by_key(owner.0) else { return; }; let current = surface.view(id).is_some_and(|v| { @@ -1803,43 +1687,139 @@ pub(crate) fn set( let waited = Rc::new(Cell::new(false)); let done = { let waited = waited.clone(); - move |result: Result| { - if let Some(surface) = Surface::by_key(key) + move |result: Result<(Picture, Lease), String>| { + if let Some(surface) = Surface::by_key(owner.0) && let Some(view) = surface.view(id) { loaded(&surface, &view, generation, result, waited.get()); } } }; - let immediate = fetch_source(surface, source, progress, Box::new(done)); + let load = fetch( + surface, + &source, + want, + Frames::All, + Priority::Visible, + progress, + Box::new(done), + ); waited.set(true); - if !immediate && events.contains(EventKind::LoadStart) { - surface.event(id, EventKind::LoadStart, EventPayload::None); + if let Some(load) = load { + if let Some(i) = view.state_mut().image.as_mut() + && i.generation == generation + { + i.load = Some(load); + } + if events.contains(EventKind::LoadStart) { + surface.event(id, EventKind::LoadStart, EventPayload::None); + } } } - let wants_placeholder = view - .state() - .image - .as_ref() - .is_some_and(|i| i.image.is_none() && i.placeholder.is_none()); - if let Some(placeholder) = options.placeholder.as_ref().filter(|_| wants_placeholder) { - let done = move |result: Result| { - let Some(view) = Surface::by_key(key).and_then(|s| s.view(id)) else { + let placeholder = view.state().image.as_ref().and_then(|i| { + let wanted = i.image.is_none() && i.placeholder.is_none() && i.placeholder_load.is_none(); + i.options.placeholder.clone().filter(|_| wanted) + }); + if let Some(placeholder) = placeholder { + let done = move |result: Result<(Picture, Lease), String>| { + let Some(view) = owner_view(owner) else { return; }; match result { - Ok(picture) => { + Ok((picture, lease)) => { if let Some(i) = view.state_mut().image.as_mut() && i.generation == generation { i.placeholder = Some(picture); + i.placeholder_lease = lease; + i.placeholder_load = None; } view.changed(); } Err(message) => log::warn!("image placeholder failed: {message}"), } }; - fetch_source(surface, placeholder, None, Box::new(done)); + let load = fetch( + surface, + &placeholder, + want, + Frames::All, + Priority::Visible, + None, + Box::new(done), + ); + if let Some(load) = load + && let Some(i) = view.state_mut().image.as_mut() + && i.generation == generation + { + i.placeholder_load = Some(load); + } + } +} + +/// `view`'s frame changed: an image now shown larger than it was decoded +/// loads the larger size, showing the smaller one meanwhile. +pub(crate) fn resized(surface: &Rc, view: &NshView) { + let (generation, owner, source, want) = { + let s = view.state(); + let frame = s.frame.size; + let Some(image) = s.image.as_ref() else { + return; + }; + let (Some(picture), Some(source)) = (&image.image, &image.source) else { + return; + }; + if image.load.is_some() { + return; + } + let want = want(surface, frame, image.mode); + let density = context(surface, &source.uri) + .locate(source) + .map_or(1.0, |l| l.density()); + let longest = want.longest(picture.natural, density, source.size); + let (w, h) = picture.pixels; + if longest <= w.max(h) { + return; + } + (image.generation, image.owner, source.clone(), want) + }; + let done = move |result: Result<(Picture, Lease), String>| { + let Some(view) = owner_view(owner) else { + return; + }; + let mut s = view.state_mut(); + let Some(image) = s.image.as_mut().filter(|i| i.generation == generation) else { + return; + }; + image.load = None; + let Ok((picture, lease)) = result else { return }; + // The same image, larger: no new load events; an animation plays + // on from the frame it was. + if image.playback.is_some() != picture.animated() { + image.playback = picture.animated().then(|| Playback::new(&picture)); + if let Some(p) = image.playback.as_mut() { + p.advance(owner); + } + } + image.image = Some(picture); + image.lease = lease; + drop(s); + view.changed(); + }; + let load = fetch( + surface, + &source, + want, + Frames::All, + Priority::Visible, + None, + Box::new(done), + ); + if let Some(load) = load + && let Some(i) = view.state_mut().image.as_mut() + && i.generation == generation + { + i.load = Some(load); } } @@ -1849,7 +1829,7 @@ fn loaded( surface: &Rc, view: &NshView, generation: u64, - result: Result, + result: Result<(Picture, Lease), String>, waited: bool, ) { let (events, id, fades) = { @@ -1858,9 +1838,11 @@ fn loaded( let Some(image) = s.image.as_mut().filter(|i| i.generation == generation) else { return; }; + image.load = None; match &result { - Ok(picture) => { + Ok((picture, lease)) => { image.image = Some(picture.clone()); + image.lease = lease.clone(); image.playback = picture.animated().then(|| Playback::new(picture)); let owner = image.owner; if let Some(p) = image.playback.as_mut() { @@ -1876,6 +1858,10 @@ fn loaded( duration: f64::from(ms) / 1000.0, }); } + // The image shows: the placeholder is no longer needed. + if image.fade.is_none() { + image.placeholder_load = None; + } } Err(message) => log::warn!("image failed: {message}"), } @@ -1886,13 +1872,10 @@ fn loaded( } view.changed(); match &result { - Ok(picture) if events.contains(EventKind::Load) => surface.event( + Ok((picture, _)) if events.contains(EventKind::Load) => surface.event( id, EventKind::Load, - EventPayload::Image(nsh_core::geometry::size( - DevicePixels(picture.size.0 as i32), - DevicePixels(picture.size.1 as i32), - )), + EventPayload::Image(reported_size(picture.natural)), ), Err(message) if events.contains(EventKind::Error) => surface.event( id, @@ -1906,6 +1889,10 @@ fn loaded( if events.contains(EventKind::LoadEnd) { surface.event(id, EventKind::LoadEnd, EventPayload::None); } + // It may have been laid out larger while it loaded. + if result.is_ok() { + resized(surface, view); + } } /// Advances the fade-in of view `id`'s load `generation` to frame time @@ -1934,36 +1921,20 @@ fn fade_tick(surface: &Rc, id: NodeId, generation: u64, now: f64) -> bo running } -/// `logo@2x.png` -> 2: the pixel density a file name declares (1 without). -fn density(uri: &str) -> f32 { - let name = uri.rsplit('/').next().unwrap_or(uri); - let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem); - stem.rsplit_once('@') - .and_then(|(_, s)| s.strip_suffix('x')) - .and_then(|n| n.parse::().ok()) - .filter(|n| *n > 0.0) - .unwrap_or(1.0) -} - -/// The natural size in points of an image of `size` pixels loaded from -/// `source`: the size the source declares, an SVG's intrinsic size -/// (`points`), or its pixels over the file name's density. -fn natural( - size: (u32, u32), - points: Option>, - source: Option<&ImageSource>, -) -> (f32, f32) { +/// The natural size in points of a picture of `own` size loaded from +/// `source`: the size the source declares, an SVG's intrinsic size, or its +/// pixels over the file name's density. +fn natural(own: Natural, source: Option<&ImageSource>) -> (f32, f32) { if let Some(size) = source .and_then(|s| s.size) - .filter(|s| s.width.0 > 0.0 && s.height.0 > 0.0) + .map(|s| (s.width.0, s.height.0)) + .filter(|(w, h)| *w > 0.0 && *h > 0.0) { - return (size.width.0, size.height.0); - } - if let Some(points) = points { - return (points.width.0, points.height.0); + return size; } - let d = source.map_or(1.0, |s| density(&s.uri)); - (size.0 as f32 / d, size.1 as f32 / d) + let d = source.map_or(1.0, |s| nsh_core::image::loader::density(&s.uri)); + let points = own.points(d); + (points.width.0, points.height.0) } /// Where an image of natural size `natural` goes in a box of `bounds` @@ -2025,7 +1996,7 @@ fn draw_one( let (x, y, fw, fh) = fit( image.mode, image.options.position, - natural(picture.size, picture.points, source), + natural(picture.natural, source), (w, h), ); if fw <= 0.0 || fh <= 0.0 || cx.culled() { @@ -2060,6 +2031,10 @@ fn draw_one( let Some(texture) = texture_for(picture, look, blur_px, image.owner.0) else { return; }; + // Its atlas copy counts in its entry once drawn. + if Rc::ptr_eq(&texture, &picture.texture) && !picture.drawn.replace(true) { + picture.count(picture.bytes() as isize); + } let boxed = cx.rect(0.0, 0.0, w, h); let placed = cx.rect(x, y, fw, fh); // GPUI rounds the sprite itself: the corners of the innermost rounded @@ -2109,121 +2084,6 @@ pub(crate) fn draw(cx: &mut DrawCx<'_>, state: &ViewState) { mod tests { use super::*; - #[test] - fn resolves_uris() { - let dir = AssetBase::Directory("/app/assets".into()); - let http = AssetBase::Http("http://10.0.0.2:8080/assets/".into()); - assert_eq!( - resolve("asset:/img/a.png", Some(&dir)), - Some(Location::File( - PathBuf::from("/app/assets").join("img/a.png") - )) - ); - assert_eq!( - resolve("asset:/a.png", Some(&http)), - Some(Location::Url("http://10.0.0.2:8080/assets/a.png".into())) - ); - assert_eq!(resolve("asset:/a.png", None), None); - assert_eq!( - resolve("https://x.org/a.jpg", None), - Some(Location::Url("https://x.org/a.jpg".into())) - ); - assert_eq!(resolve("relative.png", None), None); - assert!(matches!( - resolve("data:image/png;base64,AA==", None), - Some(Location::Data(_)) - )); - if cfg!(windows) { - assert_eq!( - resolve("file:///C:/Users/a%20b.png", None), - Some(Location::File("C:\\Users\\a b.png".into())) - ); - assert_eq!( - resolve("file://server/share/x.png", None), - Some(Location::File("\\\\server\\share\\x.png".into())) - ); - assert_eq!( - resolve("C:\\Users\\a.png", None), - Some(Location::File("C:\\Users\\a.png".into())) - ); - } else { - assert_eq!( - resolve("file:///home/a%20b.png", None), - Some(Location::File("/home/a b.png".into())) - ); - assert_eq!( - resolve("file://localhost/x.png", None), - Some(Location::File("/x.png".into())) - ); - assert_eq!(resolve("file://server/share/x.png", None), None); - assert_eq!( - resolve("/home/a.png", None), - Some(Location::File("/home/a.png".into())) - ); - } - } - - #[test] - fn decodes_data_uris() { - assert_eq!(data_uri("data:image/png;base64,aGk=").unwrap(), b"hi"); - assert_eq!(data_uri("data:image/png;base64,aG\n k").unwrap(), b"hi"); - assert_eq!(data_uri("data:text/plain,a%20b%zz").unwrap(), b"a b%zz"); - assert!(data_uri("data:nocomma").is_err()); - assert!(data_uri("data:;base64,@@").is_err()); - } - - #[test] - fn reads_inline_bytes_by_digest_key() { - let source = ImageSource::default().with_bytes("image/svg+xml", SVG_ICON.to_vec().into()); - let same = ImageSource::default().with_bytes("image/svg+xml", SVG_ICON.to_vec().into()); - let location = Location::Memory(source.uri.to_string(), source.data.unwrap()); - assert_eq!(location.key(), format!("inline:{}", same.uri)); - assert!( - location - .key() - .starts_with("inline:data:image/svg+xml;nsh-key=") - ); - assert!( - data_uri(&source.uri).is_err(), - "the key is not a data URI payload" - ); - let bytes = read(&location, &Access::default(), None, &mut |_, _| {}).unwrap(); - assert_eq!(bytes.as_ref(), SVG_ICON); - let Location::Memory(_, original) = &location else { - unreachable!() - }; - assert_eq!(&bytes, original, "inline reads keep shared original bytes"); - assert_eq!(decode(bytes).unwrap().size, (24, 24)); - } - - #[test] - fn raw_uris_cannot_address_inline_cache_entries() { - // Blob MIME types can contain commas: excluding only malformed - // data URIs would still let these internal keys reach the cache. - for mime in ["image/svg+xml", "image/svg+xml,x"] { - let source = ImageSource::default().with_bytes(mime, SVG_ICON.to_vec().into()); - let memory = Location::Memory(source.uri.to_string(), source.data.unwrap()); - let raw = resolve(&source.uri, None).unwrap(); - assert_ne!(raw.key(), memory.key()); - assert_eq!(resolve(&memory.key(), None), None); - let loader = Loader::default(); - loader.insert(memory.key(), picture(1, 1)); - loader - .inflight - .borrow_mut() - .insert(memory.key(), Vec::new()); - assert!(loader.cached(&memory.key()).is_some()); - assert!(loader.cached(&raw.key()).is_none()); - assert!(!loader.inflight.borrow().contains_key(&raw.key())); - assert_eq!( - read(&memory, &Access::default(), None, &mut |_, _| {}) - .unwrap() - .as_ref(), - SVG_ICON - ); - } - } - #[test] fn disk_cache_is_remote_only_and_cleared_with_load() { let surface = Surface::new(nsh_engine::Environment::default()); @@ -2233,13 +2093,15 @@ mod tests { ); let cache = DiskCache::open("first-engine-cache", nsh_core::ByteSize::mib(1)).unwrap(); set_disk_cache(&surface, Some(cache)); - let retained = disk_cache(&surface, "https://example.test/image.png").unwrap(); + let retained = context(&surface, "https://example.test/image.png") + .disk + .unwrap(); assert_eq!(retained.dir(), std::path::Path::new("first-engine-cache")); - assert!(disk_cache(&surface, "http://example.test/image.png").is_some()); - assert!(matches!( - resolve("asset:/image.png", surface.asset_base.borrow().as_ref()), - Some(Location::Url(_)) - )); + assert!( + context(&surface, "http://example.test/image.png") + .disk + .is_some() + ); for uri in [ "asset:/image.png", "data:image/png;base64,AA==", @@ -2247,12 +2109,16 @@ mod tests { "file:/image.png", ] { assert!( - disk_cache(&surface, uri).is_none(), + context(&surface, uri).disk.is_none(), "{uri} is never disk cached" ); } surface.stop(); - assert!(disk_cache(&surface, "https://example.test/image.png").is_none()); + assert!( + context(&surface, "https://example.test/image.png") + .disk + .is_none() + ); // A running worker retains its original handle across a replacement load. set_disk_cache( &surface, @@ -2260,7 +2126,8 @@ mod tests { ); assert_eq!(retained.dir(), std::path::Path::new("first-engine-cache")); assert_eq!( - disk_cache(&surface, "https://example.test/image.png") + context(&surface, "https://example.test/image.png") + .disk .unwrap() .dir(), std::path::Path::new("second-engine-cache") @@ -2268,81 +2135,42 @@ mod tests { } #[test] - fn inline_bytes_bypass_asset_resolution_and_keep_load_guards() { + fn inline_bytes_bypass_missing_engine_assets() { let surface = Surface::new(nsh_engine::Environment::default()); + set_asset_base( + &surface, + Some(AssetBase::Directory("fallback-assets".into())), + ); *surface.image_assets.borrow_mut() = AssetSource::Engine(None); let source = ImageSource { uri: "asset:/inline-load-guard.svg".into(), data: Some(SVG_ICON.to_vec().into()), ..Default::default() }; - assert!(super::source(&surface, &source.uri).is_err()); - - // Stand in for an existing worker so callbacks can be driven without - // a GPUI event loop. Attached bytes must bypass the missing reader. - let key = format!("inline:{}", source.uri); - LOADER.with(|l| { - l.inflight.borrow_mut().insert( - key.clone(), - vec![Waiter { - done: Box::new(|_| {}), - progress: None, - }], - ); - }); - let completed = Rc::new(Cell::new(false)); - let progressed = Rc::new(Cell::new(0)); - let done = { - let completed = completed.clone(); - Box::new(move |_| completed.set(true)) as Done - }; - let progress = { - let progressed = progressed.clone(); - Box::new(move |_, _| progressed.set(progressed.get() + 1)) as Progress - }; - assert!(!fetch_source(&surface, &source, Some(progress), done)); - LOADER.with(|l| l.progress(&key, 1, 2)); - assert_eq!(progressed.get(), 1); - surface.stop(); - LOADER.with(|l| l.progress(&key, 2, 2)); - finish(key, Err("the old load finished".into())); - assert_eq!(progressed.get(), 1); - assert!(!completed.get()); - } - - #[test] - fn bounds_huge_images() { - assert_eq!(bounded((100, 50)), (100, 50)); - assert_eq!(bounded((16384, 4096)), (8192, 2048)); - assert_eq!(bounded((10, 100_000)), (1, 8192)); - } - - fn picture(w: u32, h: u32) -> Picture { - let frame = image::Frame::new(image::RgbaImage::new(w, h)); - Image::shared(Decoded::new(vec![frame], Vec::new(), 0, (w, h))) + let context = context(&surface, &source.uri); + assert!(context.resolve(&source.uri).is_none()); + let location = context.locate(&source).unwrap(); + let bytes = nsh_core::image::loader::read_now(&location, &context).unwrap(); + assert_eq!(bytes.as_ref(), SVG_ICON); } #[test] - fn evicts_the_least_recently_used() { - let loader = Loader::default(); - // 16 MB each: eight fill the cache. - for i in 0..8 { - loader.insert(format!("{i}"), picture(2048, 2048)); - } - assert_eq!(loader.bytes.get(), CACHE_BYTES); - assert!(loader.cached("0").is_some()); - loader.insert("8".into(), picture(2048, 2048)); - // "1" was the oldest once "0" was used. - assert!(loader.cached("1").is_none()); - assert!(loader.cached("0").is_some()); - assert!(loader.cached("8").is_some()); - assert_eq!(loader.bytes.get(), CACHE_BYTES); - // Replacing an entry counts it once. - loader.insert("8".into(), picture(1024, 1024)); - assert_eq!(loader.bytes.get(), CACHE_BYTES - (12 << 20)); - // Too big to cache. - loader.insert("big".into(), picture(8192, 4096)); - assert!(loader.cached("big").is_none()); + fn scales_to_the_size_shown() { + assert_eq!(scaled((100, 50), 0), (100, 50)); + assert_eq!(scaled((100, 50), 512), (100, 50), "never enlarged"); + assert_eq!(scaled((4000, 3000), 512), (512, 384)); + assert_eq!(scaled((16384, 4096), 0), (8192, 2048)); + assert_eq!(scaled((10, 100_000), 0), (1, 8192)); + } + + /// `bytes` decoded with its longest side `longest` pixels (0: its own). + fn decoded(bytes: &[u8], longest: u32) -> Result { + let target = Target { + natural: Natural::of(bytes), + longest, + frames: Frames::All, + }; + decode(bytes, &target) } #[test] @@ -2351,10 +2179,22 @@ mod tests { .join("../../../examples/gallery/logo@2x.png"); let bytes = std::fs::read(path).unwrap(); let rgba = image::load_from_memory(&bytes).unwrap().into_rgba8(); - let decoded = decode(bytes).unwrap(); - let size = decoded.image.size(0); - assert_eq!((size.width.0 as u32, size.height.0 as u32), decoded.size); - assert_eq!(decoded.size, rgba.dimensions()); + let decoded = decoded(&bytes, 0).unwrap(); + let (w, h) = rgba.dimensions(); + assert_eq!(decoded.size(), (w, h)); + assert_eq!(decoded.natural, Natural::Pixels(w, h)); + // Shrunk as it decodes, its own size kept. + let small = super::decode( + &bytes, + &Target { + natural: Natural::of(&bytes), + longest: 16, + frames: Frames::First, + }, + ) + .unwrap(); + assert_eq!(small.size().0.max(small.size().1), 16); + assert_eq!(small.natural, decoded.natural); let bgra = decoded.image.as_bytes(0).unwrap(); // Red and blue swapped, alpha straight. let pixels = rgba.as_raw().as_chunks::<4>().0.iter(); @@ -2363,19 +2203,59 @@ mod tests { } } + /// A 4×2 JPEG whose EXIF orientation is `orientation` (1–8). + fn jpeg_turned(orientation: u16) -> Vec { + use image::ImageEncoder; + // A little-endian TIFF block with one entry: Orientation (0x0112), + // a SHORT. + let mut exif = b"II*\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&orientation.to_le_bytes()); + exif.extend_from_slice(&[0; 6]); + let mut bytes = Vec::new(); + let mut encoder = image::codecs::jpeg::JpegEncoder::new(&mut bytes); + encoder.set_exif_metadata(exif).unwrap(); + let pixels = image::RgbImage::from_pixel(4, 2, image::Rgb([200, 40, 40])); + encoder + .write_image(pixels.as_raw(), 4, 2, image::ExtendedColorType::Rgb8) + .unwrap(); + bytes + } + + #[test] + fn get_size_reports_photos_upright() { + // 5–8 turn the picture a quarter: its sides swap, as the decode's do. + for orientation in 5..=8 { + let bytes = jpeg_turned(orientation); + assert_eq!(Natural::of(&bytes), Some(Natural::Pixels(4, 2))); + assert_eq!( + upright(&bytes), + Some(Natural::Pixels(2, 4)), + "{orientation}" + ); + assert_eq!(decoded(&bytes, 0).unwrap().natural, Natural::Pixels(2, 4)); + } + for orientation in 1..=4 { + let bytes = jpeg_turned(orientation); + assert_eq!( + upright(&bytes), + Some(Natural::Pixels(4, 2)), + "{orientation}" + ); + } + } + #[test] fn reads_densities() { - assert_eq!(density("asset:/logo@2x.png"), 2.0); - assert_eq!(density("asset:/a@3x.webp"), 3.0); - assert_eq!(density("https://x.org/me@home/a.png"), 1.0); - assert_eq!(density("a.png"), 1.0); let source = ImageSource { uri: "asset:/logo@2x.png".into(), size: None, scales: Default::default(), data: None, }; - assert_eq!(natural((200, 100), None, Some(&source)), (100.0, 50.0)); + assert_eq!( + natural(Natural::Pixels(200, 100), Some(&source)), + (100.0, 50.0) + ); } #[test] @@ -2439,8 +2319,11 @@ mod tests { ] { let bytes = std::fs::read(dir.join(name)).unwrap(); assert_eq!(animated_format(&bytes), Some(format)); - let decoded = animation(&bytes, format).unwrap().unwrap(); + let decoded = animation(&bytes, format, 0).unwrap().unwrap(); assert!(decoded.image.frame_count() > 1, "{name}"); + let small = animation(&bytes, format, 8).unwrap().unwrap(); + assert!(small.size().0.max(small.size().1) <= 8, "{name}"); + assert_eq!(small.natural, decoded.natural); assert_eq!(decoded.image.frame_count(), decoded.delays.len()); assert!(decoded.delays.iter().all(|&d| d >= 20)); } @@ -2516,8 +2399,8 @@ mod tests { const SVG_LOGO: &[u8] = br##""##; /// The BGRA pixel at (`x`, `y`) of the first frame. - fn bgra(decoded: &Decoded, x: u32, y: u32) -> [u8; 4] { - let width = decoded.image.size(0).width.0 as u32; + fn bgra(decoded: &Pixels, x: u32, y: u32) -> [u8; 4] { + let width = decoded.size().0; let i = ((y * width + x) * 4) as usize; decoded.image.as_bytes(0).unwrap()[i..i + 4] .try_into() @@ -2525,28 +2408,21 @@ mod tests { } #[test] - fn svg_decodes_oversampled() { - let logo = decode(SVG_LOGO).unwrap(); - assert_eq!(logo.points, Some(Size::new(Points(48.0), Points(24.0)))); - assert_eq!(logo.size, (48, 24), "intrinsic size in points"); - let pixels = logo.image.size(0); + fn svg_decodes_at_the_size_shown() { + let logo = decoded(SVG_LOGO, 192).unwrap(); assert_eq!( - (pixels.width.0, pixels.height.0), - (192, 96), - "four pixels a point" + logo.natural, + Natural::Vector(Size::new(Points(48.0), Points(24.0))) ); + assert_eq!(logo.size(), (192, 96), "four pixels a point"); // Straight BGRA: the half-transparent blue is not premultiplied. assert_eq!(bgra(&logo, 10, 10), [0, 0, 255, 255]); let [b, g, r, a] = bgra(&logo, 150, 50); assert_eq!((b, g, r), (255, 0, 0)); assert!((127..=128).contains(&a), "alpha {a}"); - // Capped: a large SVG draws at most 2048 pixels a side. let big = br#""#; - let big = decode(big).unwrap(); - let pixels = big.image.size(0); - assert_eq!((pixels.width.0, pixels.height.0), (2048, 512)); - assert_eq!(big.size, (1000, 250)); - assert!(decode(b""#; - let odd = decode(odd).unwrap(); - assert_eq!(odd.size, (11, 10)); - assert_eq!(natural(odd.size, odd.points, None), (10.5, 10.0)); + let odd = decoded(odd, 32).unwrap(); + let size = reported_size(odd.natural); + assert_eq!((size.width.0, size.height.0), (11, 10)); + assert_eq!(natural(odd.natural, None), (10.5, 10.0)); } #[test] @@ -2577,8 +2449,8 @@ mod tests { // still draws neither (no fonts loaded, the resolver resolves // nothing). let svg = br##"NSH"##; - let decoded = decode(svg).unwrap(); - assert_eq!(decoded.size, (20, 10)); + let decoded = decoded(svg, 20).unwrap(); + assert_eq!(decoded.size(), (20, 10)); let bytes = decoded.image.as_bytes(0).unwrap(); assert!(bytes.iter().all(|&b| b == 0), "nothing drawn"); } @@ -2586,9 +2458,8 @@ mod tests { #[test] fn svg_tints_through_alpha() { // A variant tinted red: the SVG's shape in red, the rest transparent. - let icon = decode(SVG_ICON).unwrap(); - let size = icon.image.size(0); - let size = (size.width.0 as u32, size.height.0 as u32); + let icon = decoded(SVG_ICON, 96).unwrap(); + let size = icon.size(); let ops = Ops { mip: 1, blur: 0, diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/mount.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/mount.rs index 1f78e62846..e6357da8f2 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/mount.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/mount.rs @@ -287,6 +287,10 @@ impl Mounter { ViewKind::WebView | ViewKind::Video | ViewKind::Native => { crate::native::resized(view) } + ViewKind::Image => { + crate::shader::resized(surface, view); + crate::image::resized(surface, view); + } _ => crate::shader::resized(surface, view), } } @@ -304,7 +308,11 @@ impl Mounter { content, } => { if let Some(view) = &self.get(*id) { + let old = view.state().text.as_ref().map(|t| t.text.clone()); crate::text::set(view, text, layout, *content); + if let Some(old) = old { + crate::transition::text_changed(surface, view, &old, text); + } crate::access::text_changed(view); } } diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/native/web.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/native/web.rs index 48a99b7187..03b6de2a4f 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/native/web.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/native/web.rs @@ -512,6 +512,10 @@ mod imp { emitter.emit(EventKind::Error, || { EventPayload::Map(vec![ ("message", PayloadValue::Str(message)), + ( + "code", + PayloadValue::Str(nsh_core::element::codes::ACCESS_DENIED.into()), + ), ("url", PayloadValue::Str(url.clone())), ]) }); diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs index 287841c2f6..67be223fa4 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/surface.rs @@ -339,7 +339,7 @@ impl Surface { pub(crate) fn start(self: &Rc, mut config: EngineConfig, use_engine_assets: bool) { self.stop(); *self.image_assets.borrow_mut() = if use_engine_assets { - crate::image::AssetSource::Engine(config.assets.clone()) + crate::image::AssetSource::engine(config.assets.clone()) } else { crate::image::AssetSource::Base }; @@ -832,11 +832,12 @@ impl Surface { self.invalidate(); } - /// Whether a native animation runs: the graph's, or the platform's own + /// Whether a native animation runs: the graph's (not one looping + /// forever, which no test step waits for), or the platform's own /// (scroll glides). #[cfg(feature = "dev")] pub(crate) fn animating(&self) -> bool { - self.anim.borrow().animating() || !self.frame_clients.borrow().is_empty() + self.anim.borrow().finishing() || !self.frame_clients.borrow().is_empty() } /// Keeps the model `nsh test --target` steps see of the app diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/system.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/system.rs index ee0995b192..0f79e66536 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/system.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/system.rs @@ -972,6 +972,7 @@ pub(crate) fn command(surface: &Rc, _id: NodeId, command: &Command) { }); } Command::PickImages { id, request } => pick_images(surface, *id, *request), + Command::FileRequest { id, request } => file_request(surface, *id, request), Command::ExitApp => { // The window goes after the batch is applied (closing it stops // the surface). An embedded surface closes the window it is in, @@ -1049,6 +1050,91 @@ fn pick_images(surface: &Rc, id: u32, request: nsh_core::picker::PickRe }); } +/// A request on local paths ([`nsh_core::file_dialog`]): GPUI's open and +/// save prompts (which take no title, filters or starting folder for +/// picks, so the files whose extension the dialog does not take are left +/// out), `open_with_system` and `reveal_path`, which report nothing back +/// (only a path that is not there fails). GPUI has no trash. +fn file_request(surface: &Rc, id: u32, request: &nsh_core::file_dialog::FileRequest) { + use nsh_core::file_dialog::{self, FileRequest, OpenDialogKind}; + let mut control = None; + surface.control(|c| control = Some(c.clone())); + let Some(control) = control else { return }; + let request = request.clone(); + crate::rt::on_app(move |cx| { + let answer = + move |payload| control.dispatch_event(ROOT_ID, EventKind::FileRequest, payload); + let missing = |path: &std::path::Path| { + EventPayload::file_failed(id, format!("`{}` does not exist", path.display())) + }; + match request { + FileRequest::OpenDialog(dialog) => { + // Files and folders pick files where GPUI cannot do both. + let (files, directories) = match dialog.kind { + OpenDialogKind::Folders => (false, true), + OpenDialogKind::FilesAndFolders => (true, cx.can_select_mixed_files_and_dirs()), + _ => (true, false), + }; + let paths = cx.prompt_for_paths(gpui::PathPromptOptions { + files, + directories, + multiple: dialog.multiple, + prompt: dialog.button_label.clone().map(Into::into), + }); + cx.spawn(async move |_| { + let payload = match paths.await { + Ok(Ok(paths)) => EventPayload::file_answer( + id, + paths + .unwrap_or_default() + .into_iter() + .filter(|p| p.is_dir() || file_dialog::passes(&dialog.filters, p)) + .collect(), + ), + Ok(Err(err)) => EventPayload::file_failed(id, err.to_string()), + Err(_) => EventPayload::file_answer(id, Vec::new()), + }; + answer(payload); + }) + .detach(); + } + FileRequest::SaveDialog(dialog) => { + let directory = dialog + .directory + .clone() + .or_else(std::env::home_dir) + .unwrap_or_default(); + let path = cx.prompt_for_new_path(&directory, dialog.file_name.as_deref()); + cx.spawn(async move |_| { + let payload = match path.await { + Ok(Ok(path)) => EventPayload::file_answer(id, path.into_iter().collect()), + Ok(Err(err)) => EventPayload::file_failed(id, err.to_string()), + Err(_) => EventPayload::file_answer(id, Vec::new()), + }; + answer(payload); + }) + .detach(); + } + FileRequest::OpenPath(path) if path.exists() => { + cx.open_with_system(&path); + answer(EventPayload::file_answer(id, Vec::new())); + } + FileRequest::RevealPath(path) if path.exists() => { + cx.reveal_path(&path); + answer(EventPayload::file_answer(id, Vec::new())); + } + FileRequest::OpenPath(path) | FileRequest::RevealPath(path) => answer(missing(&path)), + FileRequest::MoveToTrash(_) => { + answer(EventPayload::file_failed(id, "GPUI has no trash")) + } + other => { + log::debug!("gpui: file request {other:?} is not supported"); + answer(EventPayload::file_failed(id, "not supported")); + } + } + }); +} + fn set_clipboard_image(uri: &str) { let Some((mime, bytes)) = clipboard::decode_data_uri(uri) else { log::warn!("Clipboard.writeImage: not a base64 data: URI"); diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/text.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/text.rs index 75f96c6737..f062c85c87 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/text.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/text.rs @@ -2320,7 +2320,12 @@ pub(crate) fn draw(cx: &mut DrawCx<'_>, state: &ViewState) { .get(span_at(&text.spans, index)) .map_or(&default, |s| &s.style) }; - let animated = state.anim.color; + // An animated color, or a transition of the text's color, over the + // runs' own. + let animated = state + .anim + .color + .or_else(|| state.live.as_ref()?.text_color()); let mut glyphs = Vec::with_capacity(layout.glyphs.len()); for g in &layout.glyphs { let o = cx.map(ox + g.x, oy + g.y); diff --git a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/transition.rs b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/transition.rs index d4bd72b3ae..d99f47b360 100644 --- a/third-party/nsh/crates/gpui/nsh-platform-gpui/src/transition.rs +++ b/third-party/nsh/crates/gpui/nsh-platform-gpui/src/transition.rs @@ -30,7 +30,7 @@ use std::rc::Rc; pub(crate) use nsh_core::transition::Live; use nsh_core::{ - Bounds, LayoutAnimation, Points, ViewKind, ViewStyle, + AttributedText, Bounds, LayoutAnimation, Points, ViewKind, ViewStyle, transition::{self, Extra}, }; @@ -185,6 +185,23 @@ pub(crate) fn style_changed(surface: &Rc, view: &NshView, old: &ViewSty animate(surface, view, |live| live.restyle(restyle)); } +/// `view`'s text changed from `old` (the new one is in its state): its +/// color transitions as its style's `transition` says, drawn over the new +/// runs ([`Live::text_color`]) rather than typeset on every frame. +pub(crate) fn text_changed( + surface: &Rc, + view: &NshView, + old: &AttributedText, + text: &AttributedText, +) { + let recolor = transition::recolor(old, text, &view.state().style, reduce_motion(surface)); + if recolor.starts() { + animate(surface, view, |live| live.recolor(recolor)); + } else if let Some(live) = view.state_mut().live.as_deref_mut() { + live.recolor(recolor); + } +} + /// True if an ancestor of `view` is animating out (and takes `view` with /// it). fn ancestor_dying(view: &NshView) -> bool { diff --git a/third-party/nsh/crates/nsh-base/Cargo.toml b/third-party/nsh/crates/nsh-base/Cargo.toml index 06dabb3e05..ace034b828 100644 --- a/third-party/nsh/crates/nsh-base/Cargo.toml +++ b/third-party/nsh/crates/nsh-base/Cargo.toml @@ -17,6 +17,7 @@ highlight = ["dep:syntect", "dep:two-face"] [dependencies] nsh-core.workspace = true +chrono.workspace = true rustc-hash.workspace = true serde_json.workspace = true serde.workspace = true @@ -33,6 +34,8 @@ syntect = { workspace = true, optional = true } # The syntaxes' data: the native runtimes keep it in their binaries; the web # runtime leaves it out and fetches the page's copy on first use (`syntax.rs`). [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# The local time zone (`Local`): the web runtime asks the page instead. +chrono = { workspace = true, features = ["clock"] } two-face = { workspace = true, optional = true } [dev-dependencies] diff --git a/third-party/nsh/crates/nsh-base/src/accordion.rs b/third-party/nsh/crates/nsh-base/src/accordion.rs new file mode 100644 index 0000000000..efc7606cfa --- /dev/null +++ b/third-party/nsh/crates/nsh-base/src/accordion.rs @@ -0,0 +1,283 @@ +//! `Accordion` and its parts: stacked sections that expand and collapse, +//! one at a time or several, with no look of their own (`gpui-base`'s +//! `Accordion`, `AccordionItem`, `AccordionHeader`, `AccordionTrigger` and +//! `AccordionPanel`; `gpui-component`'s `Accordion::multiple`). +//! +//! The root holds the app's `value`, the open items' values, and reports +//! the next one with `onChange`; each item finds it through context and +//! hands its trigger and panel whether it is open. A panel opens and +//! closes as a `Collapsible` does (`collapsible::follow_open`), with the +//! same reveal. + +use std::rc::Rc; + +use nsh_core::{AccessibilityState, element::Style, prelude::*}; + +use crate::collapsible::follow_open; + +/// What an [`AccordionItem`] finds of its [`Accordion`] through context. +#[derive(Clone)] +struct AccordionContext { + value: Prop>, + multiple: Prop, + disabled: Prop, + change: Rc)>, +} + +/// What an [`AccordionTrigger`] and an [`AccordionPanel`] find of their +/// [`AccordionItem`] through context. +#[derive(Clone)] +struct ItemContext { + open: Memo, + disabled: Memo, + toggle: Rc, +} + +/// The props of [`Accordion`]. +#[derive(Debug, Clone, PartialEq, Props)] +pub struct AccordionProps { + /// The open items' `value`s. The app owns them: a trigger's press + /// reports the next ones with `onChange`, and the accordion shows the + /// ones the app passes back. + #[prop(default)] + pub value: Prop>, + /// Several items open at once (`gpui-component`'s `multiple`): opening + /// one leaves the others open. Without it, opening an item closes the + /// one that was. + #[prop(default)] + pub multiple: Prop, + /// Every item is disabled: no trigger takes presses, and screen + /// readers say each is dimmed. + #[prop(default)] + pub disabled: Prop, + /// The [`AccordionItem`]s, and whatever the app lays out between them. + pub children: Prop, +} + +/// The events of an [`Accordion`]. +#[derive(Debug, Clone, PartialEq, Event)] +pub enum AccordionEvent { + /// A trigger was pressed (a tap, a click, Space or Return, a screen + /// reader's activation): the open items' values as they would be. An + /// open item's value leaves the list; a closed one's joins it at the + /// end, or, without `multiple`, replaces it. It runs before anything + /// changes: set `value` to follow it. + #[args(value)] + Change(Vec), +} + +/// Stacked sections that each expand and collapse under a header (the +/// questions of an FAQ, the groups of a settings page), one at a time or, +/// with `multiple`, several. It holds the app's `value`, the values of the +/// open [`AccordionItem`]s, and reports the next one with `onChange` when +/// a trigger is pressed; it has no look: its `class` and `style` lay out +/// and style the stack. Screen readers hear it as a group. +/// +/// The keyboard: each [`AccordionTrigger`] is a stop of the keyboard focus +/// where views that take presses are (macOS, the web), in the order of the +/// tree, and Space and Return press it. `gpui-base` binds no arrow keys +/// between the triggers, and neither does this. +#[component] +pub fn Accordion(props: AccordionProps, cx: Scope) -> impl IntoElement { + provide_context(AccordionContext { + value: props.value, + multiple: props.multiple, + disabled: props.disabled, + change: Rc::new(move |value| cx.emit(AccordionEvent::Change(value))), + }); + view().role(AccessibilityRole::Group).child(props.children) +} + +/// The props of [`AccordionItem`]. +#[derive(Debug, Clone, PartialEq, Props)] +pub struct AccordionItemProps { + /// What identifies the item in the accordion's `value`: the app's, + /// never its title (which changes with the language). + pub value: Prop, + /// Its trigger takes no presses, and screen readers say it is dimmed; + /// it stays open or closed as the accordion's `value` says. + #[prop(default)] + pub disabled: Prop, + /// The item's [`AccordionHeader`] (with its [`AccordionTrigger`]) and + /// [`AccordionPanel`], and whatever else the app lays out in it. + pub children: Prop, +} + +/// One section of an [`Accordion`]: a header whose trigger expands and +/// collapses it, and the panel that shows while it is open. Open while the +/// accordion's `value` holds its `value`, which it hands its trigger and +/// panel; outside an accordion it stays closed. It has no look or role of +/// its own: its `class` and `style` lay out and style the section (a +/// divider between items, a card around each). +#[component] +pub fn AccordionItem(props: AccordionItemProps, _cx: Scope) -> impl IntoElement { + let accordion = use_context::(); + let open = { + let accordion = accordion.clone(); + computed(move || { + accordion.as_ref().is_some_and(|a| { + let value = props.value.get(); + a.value.with(|open| open.contains(&value)) + }) + }) + }; + let disabled = { + let accordion = accordion.clone(); + computed(move || { + props.disabled.get() || accordion.as_ref().is_some_and(|a| a.disabled.get()) + }) + }; + let toggle = Rc::new(move || { + let Some(a) = &accordion else { return }; + let value = props.value.get_untracked(); + let mut next = a.value.get_untracked(); + if let Some(at) = next.iter().position(|v| *v == value) { + next.remove(at); + } else if a.multiple.get_untracked() { + next.push(value); + } else { + next = vec![value]; + } + (a.change)(next); + }); + provide_context(ItemContext { + open, + disabled, + toggle, + }); + view().child(props.children) +} + +/// The props of [`AccordionHeader`]. +#[derive(Debug, Clone, PartialEq, Props)] +pub struct AccordionHeaderProps { + /// Its level among the page's headings, for screen readers (1 the top; + /// `gpui-base`'s default, 3). + #[prop(default = 3)] + pub level: Prop, + /// The item's [`AccordionTrigger`], and whatever else the app puts in + /// the header (an icon, a badge, an action of its own). + pub children: Prop, +} + +/// The heading of an [`AccordionItem`], around its [`AccordionTrigger`]: +/// screen readers hear a heading of its `level` and can move between the +/// items by their headings. Its `class` and `style` lay it out. +#[component] +pub fn AccordionHeader(props: AccordionHeaderProps, _cx: Scope) -> impl IntoElement { + view() + .role(AccessibilityRole::Header) + .accessibility_level(props.level) + .child(props.children) +} + +/// The props of [`AccordionTrigger`]. +#[derive(Debug, Clone, PartialEq, Props)] +pub struct AccordionTriggerProps { + /// The style while its item is open, as `aria-expanded:` classes. + #[prop(default)] + pub expanded_style: Prop() .register::() @@ -232,12 +299,21 @@ pub mod parts { .register::() .register::() .register::() + .register::() .register::() .register::() + .register::() .register::() + .register::() + .register::() + .register::() + .register::() + .register::() + .register::() .register::` in a browser). The picked images reach JS //! as `File`s whose bytes stay native ([`crate::picker`]). Needs the -//! `picker` capability when the app declares capabilities. +//! `picker` capability when the app declares capabilities: without it a +//! pick rejects with `ERR_ACCESS_DENIED` (checked here, in the call; the +//! core also refuses the picks of Rust components, which then get none). use crate::{ element::{ - JsError, JsHost, JsIn, JsObject, JsOwned, JsValue, Later, PropEnum, PropError, TsTypes, - module, + JsError, JsHost, JsIn, JsObject, JsOwned, JsValue, Later, PropEnum, PropError, System, + TsTypes, module, }, geometry::{DevicePixels, Size}, picker::{self, PickRequest, PickedFile}, @@ -26,10 +28,20 @@ impl Picker { /// formats browsers do not show come as JPEG unless `format` is /// `"current"`; `maxDimension` scales large photos down natively. On /// iOS and Android the system's photo picker needs no permission. + /// Rejects with `ERR_ACCESS_DENIED` when the `picker` capability is not + /// granted, and with a `RangeError` for options out of range. pub(crate) fn pick_images( options: Option, + sys: System, ) -> Result>, JsError> { let request = options.unwrap_or_default().request()?; + if !sys.access.allows_picker() { + return Err(JsError::access_denied( + "the picker is not granted: add `\"picker\": true` to capabilities in nsh.json", + "picker", + "", + )); + } Ok(Later::new(async move { let files = crate::prelude::pick_images(request).await; Ok::<_, JsError>(files.into_iter().map(PickedImage).collect()) diff --git a/third-party/nsh/crates/nsh-core/src/runtime/process.rs b/third-party/nsh/crates/nsh-core/src/runtime/process.rs index 6cc161286a..42f1d88ebd 100644 --- a/third-party/nsh/crates/nsh-core/src/runtime/process.rs +++ b/third-party/nsh/crates/nsh-core/src/runtime/process.rs @@ -17,7 +17,7 @@ use std::{ use crate::element::{ CallCx, Callback, Instance, JsError, JsObject, JsValue, Later, Local, PropEnum, PropError, - System, TsTypes, class, module, + System, TsTypes, class, codes, module, }; /// Subprocesses owned by this app's VM, and its process metadata. @@ -42,8 +42,13 @@ impl Process { /// deadline. A child's environment is PATH, HOME, USERPROFILE, /// SystemRoot, TEMP, TMP, TMPDIR, LANG and LC_* from the host, plus the /// names capabilities.process.env grants; never the whole host - /// environment. A refused command or option throws; a launch failure, - /// a timeout and output past the limit reject. + /// environment. Every failure rejects: a command + /// capabilities.process.execute does not grant (or a `cwd` outside + /// capabilities.fs.write) with `code` `ERR_ACCESS_DENIED`, a platform + /// without processes with `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, a + /// launch failure with the OS's code (`ENOENT`), the deadline with + /// `ETIMEDOUT`, output past the limit with + /// `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`. pub(crate) fn run( command: String, args: Option>, @@ -64,14 +69,15 @@ impl Process { #[cfg(target_arch = "wasm32")] { let _ = (launch, cx); - Err(JsError::Type(UNAVAILABLE.into())) + Err(unavailable()) } } /// Starts `command` as `run` does and streams its decoded UTF-8 output, /// or complete lines with `lines: true`; stdin is closed unless /// `stdin: "pipe"`. Listeners get the event's payload only (gpui-shell's - /// also get a context). + /// also get a context). It throws what `run` rejects with before the + /// launch. pub(crate) fn spawn( command: String, args: Option>, @@ -98,24 +104,29 @@ impl Process { #[cfg(target_arch = "wasm32")] { let _ = (launch, cx); - Err(JsError::Type(UNAVAILABLE.into())) + Err(unavailable()) } } /// Asks the host to exit (capabilities.process.exit); never exits the - /// host process directly. + /// host process directly. Throws `ERR_ACCESS_DENIED` (`permission` + /// `"process"`, `resource` `"exit"`) when not granted, and + /// `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` when the host takes no exit + /// requests. pub(crate) fn exit(code: Option, sys: System) -> Result<(), JsError> { if !sys.access.allows_exit() { - return Err(JsError::Type( + return Err(JsError::access_denied( "process.exit() is not granted; set capabilities.process.exit to true in the \ - manifest" - .into(), + manifest", + "process", + "exit", )); } let handler = sys.exit.ok_or_else(|| { JsError::Error( "process.exit() is granted but this host installed no exit request handler".into(), ) + .with_code(codes::UNAVAILABLE) })?; handler(code.unwrap_or(0)); Ok(()) @@ -187,8 +198,10 @@ pub(crate) enum Stdin { /// What `run` resolves with. #[derive(Debug, Clone, Default, JsObject)] pub(crate) struct CommandOutput { - /// The exit status; -1 when a signal ended the child. - code: i32, + /// The exit status, `null` when a signal ended the child. + code: Option, + /// The signal that ended it (`"SIGTERM"`), `null` when it exited. + signal: Option, stdout: String, stderr: String, } @@ -200,10 +213,14 @@ pub(crate) struct ExitResult { code: Option, /// The signal that ended it (`"SIGTERM"`). signal: Option, - /// Why the runtime ended it (a timeout, an over-long line); absent - /// otherwise. + /// Why the runtime ended it (a timeout, an over-long line): the message + /// `run` would reject with; absent otherwise. #[prop(skip_none)] error: Option, + /// The `code` of `error`, as `run`'s rejection carries it (`ETIMEDOUT`, + /// `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`); absent with it. + #[prop(skip_none)] + error_code: Option, } /// A child's events. @@ -290,16 +307,18 @@ impl ChildProcess { let mut listeners = child.listeners.borrow_mut(); let list = &mut listeners[event as usize]; if list.len() >= MAX_LISTENERS { - return Err(JsError::Type(format!( + return Err(JsError::Error(format!( "a child takes at most {MAX_LISTENERS} listeners per event" - ))); + )) + .with_code(codes::QUOTA_EXCEEDED)); } list.push(handler); drop(listeners); Ok(this) } - /// Queues UTF-8 for stdin (`stdin: "pipe"`); at most 1 MiB may wait. + /// Queues UTF-8 for stdin (`stdin: "pipe"`); at most 1 MiB may wait + /// (`ERR_NSH_QUOTA_EXCEEDED` past it; `EPIPE` once the child is gone). pub(crate) fn write(&self, text: String) -> Result<(), JsError> { if !self.child.stdin_open.get() { return Err(JsError::Type( @@ -311,7 +330,7 @@ impl ChildProcess { #[cfg(target_arch = "wasm32")] { let _ = text; - Err(JsError::Type(UNAVAILABLE.into())) + Err(unavailable()) } } @@ -334,8 +353,11 @@ impl ChildProcess { /// `run`'s deadline when none is given. const RUN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const MAX_LISTENERS: usize = 64; -#[cfg(target_arch = "wasm32")] -const UNAVAILABLE: &str = "process is available on desktop hosts"; + +/// What a platform without processes fails with. +fn unavailable() -> JsError { + JsError::Error("process is available on desktop hosts".into()).with_code(codes::UNAVAILABLE) +} fn timeout(ms: Option) -> Result, JsError> { match ms { @@ -400,7 +422,7 @@ mod native { use super::{Child, ChildEvent, ChildProcess, CommandOutput, ExitResult, Payload}; use crate::{ access::{Access, env_names_match}, - element::{CallCx, Held, JsError, Later, System}, + element::{CallCx, Held, JsError, Later, System, codes}, }; /// Output one VM holds in flight, and what `run` captures per stream @@ -437,7 +459,8 @@ mod native { } pub(super) enum Event { Output(bool, String), - Exit(Option, Option, Option), + /// Its status, its signal, and why the runtime ended it. + Exit(Option, Option, Option), } /// The VM's side of its children. @@ -527,18 +550,16 @@ mod native { target_os = "windows" )) || !sys.os.is_desktop() { - return Err(JsError::Type( - "process is available on desktop hosts".into(), - )); + return Err(super::unavailable()); } if command.is_empty() || command.contains('\0') { return Err(JsError::Type("invalid executable".into())); } if !sys.access.allows_process(&command) { - return Err(spawn_error( - JsError::Type(format!("process capability denied for `{command}`")) - .with_code("EACCES"), - &command, + return Err(JsError::access_denied( + format!("`{command}` is not granted by capabilities.process.execute"), + "process", + command, )); } if args.len() > 4096 { @@ -554,10 +575,11 @@ mod native { &sys.paths, true, ) - .map_err(|e| { - JsError::Type(e) - .with_code("EACCES") - .with("path", cwd.as_str()) + .map_err(|e| match e { + crate::access::FileRefusal::NotGranted(why) => { + JsError::access_denied(format!("cwd `{cwd}`: {why}"), "fs", &cwd) + } + e => JsError::Type(format!("cwd `{cwd}`: {e}")), })?, // The app's own data directory, which needs no grant. None => { @@ -569,13 +591,20 @@ mod native { ) })?; std::fs::create_dir_all(&dir).map_err(|e| { - JsError::Type(format!("could not create the app data directory: {e}")) + let message = format!("could not create the app data directory: {e}"); + match crate::element::io_code(&e) { + Some(code) => JsError::Error(message).with_code(code), + None => JsError::Error(message), + } })?; dir } }; if !cwd.is_dir() { - return Err(JsError::Type("cwd must be an existing directory".into())); + let code = if cwd.exists() { "ENOTDIR" } else { "ENOENT" }; + return Err(JsError::Error("cwd must be an existing directory".into()) + .with_code(code) + .with("path", cwd.to_string_lossy().into_owned())); } Ok(Self { command, @@ -675,7 +704,8 @@ mod native { fn launch_child(sys: &System, launch: Launch, stdin: bool) -> Result { let children = sys.locals.get::(); let place = sys.quotas.take(CHILDREN, MAX_CHILDREN).ok_or_else(|| { - JsError::Range(format!("a VM may run at most {MAX_CHILDREN} children")) + JsError::Error(format!("a VM may run at most {MAX_CHILDREN} children")) + .with_code(codes::QUOTA_EXCEEDED) })?; let mut cmd = Command::new(resolve_executable(&launch.command)?); cmd.env_clear() @@ -788,7 +818,9 @@ mod native { let mut overflowed = false; loop { let Some(event) = events.recv().await else { - return Err("the process ended without an exit status".to_owned()); + return Err(JsError::Error( + "the process ended without an exit status".to_owned(), + )); }; match event { Event::Output(err, chunk) => { @@ -802,19 +834,20 @@ mod native { captured.push_str(&chunk); } } - Event::Exit(code, _, error) => { + Event::Exit(code, signal, error) => { children.inner.finished(id); cleanup.release(); if overflowed { - return Err(format!( + return Err(max_buffer(format!( "process output exceeded {QUEUE_BYTES} bytes on one stream" - )); + ))); } if let Some(error) = error { return Err(error); } return Ok(CommandOutput { - code: code.unwrap_or(-1), + code, + signal: signal.map(signal_name), stdout, stderr, }); @@ -880,9 +913,9 @@ mod native { while let Some(part) = parts.next() { pending[stream].push_str(part); if pending[stream].len() > QUEUE_BYTES { - line_error = Some( + line_error = Some(max_buffer( "a line of output exceeded 8 MiB without a newline".to_owned(), - ); + )); pending[stream].clear(); kill(&child); break; @@ -906,10 +939,12 @@ mod native { // An owner that lives long must not collect one cleanup // per child it ever ran. cleanup.release(); + let error = line_error.or(error); let exit = ExitResult { code, signal: signal.map(signal_name), - error: line_error.or(error), + error: error.as_ref().map(|e| e.message().to_owned()), + error_code: error.as_ref().and_then(|e| e.code()).map(str::to_owned), }; *child.exited.borrow_mut() = Some(exit.clone()); child.stdin_open.set(false); @@ -929,12 +964,19 @@ mod native { let len = text.len(); if control.queued_input.fetch_add(len, Ordering::AcqRel) + len > MAX_STDIN { control.queued_input.fetch_sub(len, Ordering::AcqRel); - return Err(JsError::Type("stdin queue exceeded 1 MB".into())); + return Err( + JsError::Error("stdin queue exceeded 1 MB".into()).with_code(codes::QUOTA_EXCEEDED) + ); } control .input .send(Input::Write(text.into_bytes())) - .map_err(|_| JsError::Type("process input is closed".into())) + .map_err(|_| JsError::Error("process input is closed".into()).with_code("EPIPE")) + } + + /// Output past what the runtime holds, as Node's `maxBuffer`. + fn max_buffer(message: String) -> JsError { + JsError::Range(message).with_code("ERR_CHILD_PROCESS_STDIO_MAXBUFFER") } pub(super) fn close_stdin(child: &Child) { @@ -996,7 +1038,7 @@ mod native { true } - fn exit(&self, code: Option, signal: Option, error: Option) { + fn exit(&self, code: Option, signal: Option, error: Option) { let _ = self.tx.send(Event::Exit(code, signal, error)); } } @@ -1495,11 +1537,15 @@ mod native { let mut kill = mailbox.stopped.load(Ordering::Acquire); if mailbox.unreadable.load(Ordering::Acquire) { kill = true; - error.get_or_insert_with(|| "process output could not be read".to_owned()); + error.get_or_insert_with(|| { + JsError::Error("process output could not be read".to_owned()) + }); } if timeout.is_some_and(|limit| started.elapsed() >= limit) { kill = true; - error.get_or_insert_with(|| "process timed out".to_owned()); + error.get_or_insert_with(|| { + JsError::Error("process timed out".to_owned()).with_code(codes::TIMED_OUT) + }); } // A pipe write may block: stdin is written on its own thread, never // this supervisor. @@ -1528,12 +1574,14 @@ mod native { Ok(true) => break, Ok(false) => {} Err(e) => { - error = Some(e.to_string()); + error = Some(e.into()); break; } } if forced.is_some_and(|at| at.elapsed() >= ABANDON) { - error = Some("the process could not be ended (it may run as another user)".into()); + error = Some(JsError::Error( + "the process could not be ended (it may run as another user)".into(), + )); abandoned = true; break; } @@ -1590,7 +1638,7 @@ mod native { _: Option, _: Option, ) -> Result { - Err(JsError::Type(super::UNAVAILABLE.into())) + Err(super::unavailable()) } } } diff --git a/third-party/nsh/crates/nsh-core/src/runtime/storage.rs b/third-party/nsh/crates/nsh-core/src/runtime/storage.rs index 7c4dc401a8..f32b21b532 100644 --- a/third-party/nsh/crates/nsh-core/src/runtime/storage.rs +++ b/third-party/nsh/crates/nsh-core/src/runtime/storage.rs @@ -1,7 +1,7 @@ //! `nsh/storage`: the app's key-value storage, strings by string key kept //! on the device (`crate::storage`). -use crate::element::{module, with_storage}; +use crate::element::{JsError, codes, module, with_storage}; /// The app's key-value storage: strings by string key, kept on the device /// across launches (up to 5 MB). Calls run in order, so a read after a @@ -21,9 +21,11 @@ impl Storage { with_storage(|s| s.get(&key)) } - /// Sets `key` to `value`; rejects when the storage would be full. - pub(crate) async fn set_item(key: String, value: String) -> Result<(), String> { - with_storage(|s| s.set(key, value)).map_err(|e| e.to_string()) + /// Sets `key` to `value`; rejects with `ERR_NSH_QUOTA_EXCEEDED` when + /// the storage would be full (the value is not set). + pub(crate) async fn set_item(key: String, value: String) -> Result<(), JsError> { + with_storage(|s| s.set(key, value)) + .map_err(|full| JsError::Error(full.to_string()).with_code(codes::QUOTA_EXCEEDED)) } /// Removes `key`. diff --git a/third-party/nsh/crates/nsh-core/src/runtime/websocket.rs b/third-party/nsh/crates/nsh-core/src/runtime/websocket.rs index ef63a058bb..2aea25eb32 100644 --- a/third-party/nsh/crates/nsh-core/src/runtime/websocket.rs +++ b/third-party/nsh/crates/nsh-core/src/runtime/websocket.rs @@ -1,6 +1,33 @@ -//! The explicit WebSocket client: capability and quota checks run in the -//! call, then either the host transport (the browser's WebSocket API) or -//! the native socket drives I/O. Both enforce bounded messages and queues. +//! `websocket`: an explicit WebSocket client. `connect(url, options)` +//! resolves a [`WebSocketConnection`] whose `read()` takes the messages in +//! arrival order; either the host transport (the browser's WebSocket API) +//! or the native socket (`native.rs`) drives its I/O, both with bounded +//! messages and queues. +//! +//! Every call returns a promise, and every failure rejects it: a URL the +//! app's network capabilities do not grant with `ERR_ACCESS_DENIED` +//! (`permission` `"network"`, `resource` the URL), a used-up quota (32 +//! connections per VM, 8 MiB of writes waiting) with +//! `ERR_NSH_QUOTA_EXCEEDED`, a deadline with `ETIMEDOUT`, a connection +//! that ended without a close handshake (or is used after it ended) with +//! `ERR_SOCKET_CLOSED`, a protocol violation with `EPROTO`, a socket error +//! with the OS's code, a close code or reason out of range with a +//! `RangeError` (`ERR_OUT_OF_RANGE`). +//! +//! A connection that closes cleanly is no failure: once its close +//! handshake completed (started by the peer or by `close(code, reason)`) +//! and every message received before it was read, `read()` resolves +//! `null`, as `net`'s `Socket.read()` does at the end of the stream, and +//! `closeCode` / `closeReason` tell the close frame's code and reason +//! (1005 for a frame without a code, 1006 for a connection that ended +//! without one). +//! +//! Unread messages are back pressure, not a failure: while 8 messages or +//! 8 MiB wait to be read, the connection stops reading from the network +//! (the native socket is not polled, so TCP holds the peer back; a host +//! transport is paused, [`WebSocketTask::pause`]) and resumes as the app +//! reads. The memory a connection keeps stays bounded: what crossed the +//! threshold is at most one message of 8 MiB more. use std::{ cell::RefCell, collections::VecDeque, @@ -14,16 +41,103 @@ use crate::{ JsonValue, element::{ Held, JsError, JsHost, JsIn, JsOwned, JsValue, Later, PropError, System, TsTypes, class, - module, + codes, module, }, websocket::{WebSocketData, WebSocketEvent, WebSocketRequest, WebSocketTask}, }; #[cfg(not(target_arch = "wasm32"))] mod native; +/// The largest message one write sends or one read returns, and the bytes +/// of unread messages past which reading pauses. const LIMIT: usize = 8 * 1024 * 1024; +/// Unread messages at which reading pauses. const UNREAD: usize = 8; +/// The longest close reason, in UTF-8 bytes: what fits a control frame's +/// 125 bytes after the code (RFC 6455 5.5). +const MAX_REASON: usize = 123; +/// The close code of a connection that ended without a close frame. +const ABNORMAL: u16 = 1006; -/// `WebSocket.connect`'s options. +/// The connection is closed: by the app, by the peer, or after a failure. +fn closed(message: impl Into) -> JsError { + JsError::Error(message.into()).with_code("ERR_SOCKET_CLOSED") +} + +/// `operation` ran out of time. +fn timed_out(operation: &str) -> JsError { + JsError::Error(format!("{operation} timed out")).with_code(codes::TIMED_OUT) +} + +/// A quota of the connection or the VM is used up. +fn exceeded(message: String) -> JsError { + JsError::Error(message).with_code(codes::QUOTA_EXCEEDED) +} + +/// What the app's capabilities do not grant: a WebSocket to `url`. +fn denied(url: &str) -> JsError { + JsError::access_denied( + format!( + "a WebSocket to `{url}` is not granted; add its host to capabilities.network.hosts or a GET rule in capabilities.network.http" + ), + "network", + url, + ) +} + +/// The VM's place for one more WebSocket. +fn slot(sys: &System) -> Result { + sys.quotas.take(WEBSOCKETS, MAX_WEBSOCKETS).ok_or_else(|| { + exceeded(format!( + "At most {MAX_WEBSOCKETS} concurrent WebSockets are allowed per VM" + )) + }) +} + +/// A connection's close frame: the code and reason it closed with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CloseStatus { + /// The close code. + code: u16, + /// The close reason. + reason: String, +} + +impl CloseStatus { + /// A connection that ended without a close frame. + fn abnormal() -> Self { + Self { + code: ABNORMAL, + reason: String::new(), + } + } + + /// `close(code, reason)`'s frame, checked as the WHATWG `close()` + /// checks it (RFC 6455 7.4): a code of 1000 (the default) or + /// 3000–4999, a reason of at most 123 UTF-8 bytes. + fn requested(code: Option, reason: Option) -> Result { + let reason = reason.unwrap_or_default(); + if reason.len() > MAX_REASON { + return Err(JsError::Range(format!( + "WebSocket close reason of {} bytes exceeded the {MAX_REASON} byte limit", + reason.len() + )) + .with_code("ERR_OUT_OF_RANGE")); + } + let code = code.unwrap_or(1000.0); + if code != 1000.0 && !((3000.0..=4999.0).contains(&code) && code.fract() == 0.0) { + return Err(JsError::Range(format!( + "WebSocket close code {code} is neither 1000 nor an integer from 3000 to 4999" + )) + .with_code("ERR_OUT_OF_RANGE")); + } + Ok(Self { + code: code as u16, + reason, + }) + } +} + +/// `connect`'s options. #[derive(Debug, Default)] pub(crate) struct ConnectOptions { /// Extra handshake headers, checked in the call. @@ -33,7 +147,7 @@ pub(crate) struct ConnectOptions { impl JsValue for ConnectOptions { fn ts_type(types: &mut TsTypes) -> String { types.declare("WebSocketConnectOptions", |_| { - "/** `WebSocket.connect`'s options. */\nexport interface WebSocketConnectOptions {\n /** Extra handshake headers; credentials and handshake-control headers are refused. */\n headers?: Record;\n}".into() + "/** `connect`'s options. */\nexport interface WebSocketConnectOptions {\n /** Extra handshake headers; credentials and handshake-control headers are refused. */\n headers?: Record;\n}".into() }) } @@ -44,7 +158,7 @@ impl JsValue for ConnectOptions { }; if let Some(key) = options.keys().find(|key| *key != "headers") { return Err(PropError(format!( - "unknown option `{key}` for WebSocket.connect(url, options); expected headers" + "unknown option `{key}` for connect(url, options); expected headers" ))); } let headers = match options.get("headers") { @@ -143,10 +257,15 @@ impl JsValue for Data { #[module] pub(crate) struct WebSocket; -#[module] +#[module(flat, default)] impl WebSocket { /// Connects to a ws/wss URL allowed by capabilities.network; up to 32 - /// connections are shared with the global WebSocket constructor. + /// connections are shared with the global WebSocket constructor. A bad + /// URL or option rejects with a `TypeError`, a URL not granted with + /// `ERR_ACCESS_DENIED`, the 33rd connection with + /// `ERR_NSH_QUOTA_EXCEEDED`, a failed handshake with the OS's code or + /// `EPROTO`, a slow one with `ETIMEDOUT`, one the host refuses (a + /// test's network) with the host's error. pub(crate) fn connect( url: String, options: Option, @@ -163,9 +282,10 @@ impl WebSocket { })); } #[cfg(target_arch = "wasm32")] - return Err(JsError::Type( - "The host has no WebSocket transport configured".into(), - )); + return Err( + JsError::Error("The host has no WebSocket transport configured".into()) + .with_code(codes::UNAVAILABLE), + ); }; let parsed = url::Url::parse(&url) .map_err(|error| JsError::Type(format!("invalid WebSocket URL: {error}")))?; @@ -175,29 +295,28 @@ impl WebSocket { || !parsed.username().is_empty() || parsed.password().is_some() { - return Err(JsError::Type("WebSocket.connect(url) expects a ws:// or wss:// URL without credentials or fragment".into())); + return Err(JsError::Type( + "connect(url) expects a ws:// or wss:// URL without credentials or fragment".into(), + )); } if !sys.access.allows_websocket(parsed.as_str()) { - return Err(JsError::Type("WebSocket network capability denied".into())); + return Err(denied(parsed.as_str())); } + sys.check_connection(parsed.as_str())?; let headers = options.map(|options| options.headers).unwrap_or_default(); if !headers.is_empty() && !transport.supports_headers() { return Err(JsError::Type( "The browser WebSocket API does not support custom handshake headers".into(), )); } - let held = sys.quotas.take(WEBSOCKETS, MAX_WEBSOCKETS).ok_or_else(|| { - JsError::Range(format!( - "At most {MAX_WEBSOCKETS} concurrent WebSockets are allowed per VM" - )) - })?; + let held = slot(&sys)?; let state = Rc::new(RefCell::new(HostState { held: Some(held), ..HostState::default() })); let events = state.clone(); // The transport owns the callback; its weak task reference in State - // lets an overflowing queue cancel I/O without retaining a cycle. + // lets an ending connection cancel I/O without retaining a cycle. let task = transport.connect( WebSocketRequest { url: parsed.into(), @@ -207,7 +326,8 @@ impl WebSocket { Box::new(move |event| HostState::event(&events, event)), ); Ok(Later::new(async move { - let task = Rc::new(RefCell::new(Some(task?))); + let task = task.map_err(JsError::Error)?; + let task = Rc::new(RefCell::new(Some(task))); state.borrow_mut().task = Rc::downgrade(&task); let host = HostConnection { task, state }; let opening = host.state.clone(); @@ -216,9 +336,11 @@ impl WebSocket { poll_fn(move |cx| { let mut state = opening.borrow_mut(); if state.open { - Poll::Ready(Ok::<_, String>(())) + Poll::Ready(Ok(())) } else if let Some(error) = &state.error { Poll::Ready(Err(error.clone())) + } else if state.status.is_some() { + Poll::Ready(Err(closed("WebSocket closed during the handshake"))) } else { state.waker = Some(cx.waker().clone()); Poll::Pending @@ -226,7 +348,7 @@ impl WebSocket { }), ) .await?; - Ok::<_, String>(WebSocketConnection { + Ok::<_, JsError>(WebSocketConnection { inner: Connection::Host(host), }) })) @@ -247,7 +369,10 @@ pub(crate) struct WebSocketConnection { #[class] impl WebSocketConnection { - /// Sends a string or Uint8Array of at most 8 MiB; queued bytes are bounded. + /// Sends a string or Uint8Array of at most 8 MiB (a `RangeError` with + /// `ERR_OUT_OF_RANGE` past it); at most 8 MiB may wait to be sent + /// (`ERR_NSH_QUOTA_EXCEEDED`). After the connection ended it rejects + /// with `ERR_SOCKET_CLOSED`. pub(crate) fn write(&self, data: Data) -> Result, JsError> { match &self.inner { #[cfg(not(target_arch = "wasm32"))] @@ -258,27 +383,25 @@ impl WebSocketConnection { Data::Bytes(bytes) => WebSocketData::Bytes(bytes), }; if data.len() > LIMIT { - return Err(JsError::Range(format!( - "WebSocket write exceeded the {LIMIT} byte limit" - ))); + return Err(too_long(data.len())); } let mut task = host.task.borrow_mut(); let result = match task.as_mut() { - None => Err("WebSocket connection is closed".to_owned()), + None => Err(closed("WebSocket connection is closed")), Some(task) => { if task.buffered_amount().saturating_add(data.len()) > LIMIT { - return Err(JsError::Range(format!( + return Err(exceeded(format!( "WebSocket writes waiting to be sent exceeded the {LIMIT} byte limit" ))); } - task.send(data) + task.send(data).map_err(JsError::Error) } }; let state = host.state.clone(); let task = host.task.clone(); Ok(Later::new(async move { if let Err(error) = result { - HostState::event(&state, WebSocketEvent::Error(error.clone())); + HostState::fail(&state, error.clone()); return Err(error); } let sent = timeout("WebSocket write", async { @@ -289,7 +412,7 @@ impl WebSocketConnection { } let task = task.borrow(); let Some(task) = task.as_ref() else { - return Err("WebSocket connection is closed".into()); + return Err(closed("WebSocket connection is closed")); }; if task.buffered_amount() == 0 { return Ok(()); @@ -301,7 +424,7 @@ impl WebSocketConnection { }) .await; if let Err(error) = &sent { - HostState::event(&state, WebSocketEvent::Error(error.clone())); + HostState::fail(&state, error.clone()); } sent })) @@ -309,31 +432,45 @@ impl WebSocketConnection { } } - /// Reads the next string or Uint8Array, in arrival order. - pub(crate) fn read(&self) -> Result, JsError> { + /// Reads the next string or Uint8Array, in arrival order. Once the + /// connection closed cleanly and what arrived before was read, it + /// resolves `null`; a connection that failed rejects instead, with why + /// (`ERR_SOCKET_CLOSED` for one that ended without a close handshake, + /// `ETIMEDOUT`, `EPROTO`, the OS's code). While 8 messages or 8 MiB wait + /// unread, the connection stops reading from the network until this + /// takes one. + pub(crate) fn read(&self) -> Result>, JsError> { match &self.inner { #[cfg(not(target_arch = "wasm32"))] Connection::Native(connection) => connection.read(), Connection::Host(host) => { if host.state.borrow_mut().reading.replace(true) { - return Err(JsError::Type( - "WebSocket.read() already has an outstanding read".into(), - )); + return Err(busy()); } let reading = Reading(host.state.clone()); let task = host.task.clone(); Ok(Later::new(async move { - let _transport = task; poll_fn(|cx| { let mut state = reading.0.borrow_mut(); - if let Some(message) = state.messages.pop_front() { + let message = state.messages.pop_front(); + if let Some(message) = message { state.bytes -= message.len(); - Poll::Ready(Ok(match message { + let resume = state.paused && !state.full(); + if resume { + state.paused = false; + } + drop(state); + if resume && let Some(task) = task.borrow_mut().as_mut() { + task.pause(false); + } + Poll::Ready(Ok(Some(match message { WebSocketData::Text(text) => Data::Text(text), WebSocketData::Bytes(bytes) => Data::Bytes(bytes), - })) + }))) } else if let Some(error) = &state.error { Poll::Ready(Err(error.clone())) + } else if state.status.is_some() { + Poll::Ready(Ok(None)) } else { state.waker = Some(cx.waker().clone()); Poll::Pending @@ -345,34 +482,56 @@ impl WebSocketConnection { } } - /// Closes the connection and discards unread messages. - pub(crate) fn close(&self) -> Later<()> { - match &self.inner { + /// Closes the connection with `code` (1000, the default, or 3000–4999) + /// and `reason` (at most 123 UTF-8 bytes; a `RangeError` with + /// `ERR_OUT_OF_RANGE` past either), discards unread messages and + /// resolves once the peer answered the close handshake; `read()` then + /// resolves `null`. + pub(crate) fn close( + &self, + code: Option, + reason: Option, + ) -> Result, JsError> { + let frame = CloseStatus::requested(code, reason)?; + Ok(match &self.inner { #[cfg(not(target_arch = "wasm32"))] - Connection::Native(connection) => connection.close(), + Connection::Native(connection) => connection.close(frame), Connection::Host(host) => { - host.state.borrow_mut().messages.clear(); - host.state.borrow_mut().bytes = 0; + let paused = { + let mut state = host.state.borrow_mut(); + state.messages.clear(); + state.bytes = 0; + // What arrives from now on is dropped, and the close + // handshake's reply must reach the state. + state.closing = true; + std::mem::take(&mut state.paused) + }; let result = host .task .borrow_mut() .as_mut() - .map(|task| task.close(1000, "")) + .map(|task| { + if paused { + task.pause(false); + } + task.close(frame.code, &frame.reason) + .map_err(JsError::Error) + }) .unwrap_or(Ok(())); let state = host.state.clone(); let task = host.task.clone(); Later::new(async move { let _transport = task; if let Err(error) = result { - HostState::event(&state, WebSocketEvent::Error(error.clone())); + HostState::fail(&state, error.clone()); return Err(error); } let closed = timeout( "WebSocket close", poll_fn(|cx| { let mut state = state.borrow_mut(); - if state.error.is_some() { - Poll::Ready(Ok::<_, String>(())) + if state.status.is_some() { + Poll::Ready(Ok(())) } else { if !state .close_wakers @@ -387,19 +546,56 @@ impl WebSocketConnection { ) .await; if let Err(error) = &closed { - HostState::event(&state, WebSocketEvent::Error(error.clone())); + HostState::fail(&state, error.clone()); } closed }) } + }) + } + + /// The code of the close frame the connection closed with: the peer's + /// (1005 when it had none), 1006 when the connection ended without one; + /// `null` while it is open. + #[getter] + pub(crate) fn close_code(&self) -> Option { + self.status().map(|status| status.code) + } + + /// The reason of the close frame the connection closed with (`""` when + /// it had none); `null` while it is open. + #[getter] + pub(crate) fn close_reason(&self) -> Option { + self.status().map(|status| status.reason) + } + + /// How the connection closed, once it did. + fn status(&self) -> Option { + match &self.inner { + #[cfg(not(target_arch = "wasm32"))] + Connection::Native(connection) => connection.status(), + Connection::Host(host) => host.state.borrow().status.clone(), } } } +/// A write of `len` bytes, past the one-message limit. +fn too_long(len: usize) -> JsError { + JsError::Range(format!( + "WebSocket write of {len} bytes exceeded the {LIMIT} byte limit" + )) + .with_code("ERR_OUT_OF_RANGE") +} + +/// A second read while one waits: a wrong use. +fn busy() -> JsError { + JsError::Type("WebSocketConnection.read() already has an outstanding read".into()) +} + async fn timeout( operation: &str, - work: impl Future>, -) -> Result { + work: impl Future>, +) -> Result { use std::future::Future; let mut work = std::pin::pin!(work); let mut timer = std::pin::pin!(crate::element::timer(std::time::Duration::from_secs(30))); @@ -408,7 +604,7 @@ async fn timeout( return Poll::Ready(result); } if timer.as_mut().poll(cx).is_ready() { - return Poll::Ready(Err(format!("{operation} timed out"))); + return Poll::Ready(Err(timed_out(operation))); } Poll::Pending }) @@ -427,10 +623,7 @@ impl Drop for HostConnection { if Rc::strong_count(&self.task) > 1 { return; } - HostState::event( - &self.state, - WebSocketEvent::Error("WebSocket connection is closed".into()), - ); + HostState::fail(&self.state, closed("WebSocket connection is closed")); self.task.borrow_mut().take(); self.state.borrow_mut().held.take(); } @@ -445,9 +638,18 @@ impl Drop for Reading { #[derive(Default)] struct HostState { open: bool, - error: Option, + /// How the connection closed, once it ended (cleanly or not). + status: Option, + /// Why the connection failed, which reads reject with once the + /// messages before it are read; `None` for a clean close, after which + /// reads resolve `null`. + error: Option, messages: VecDeque, bytes: usize, + /// The transport is paused: the unread messages fill their queue. + paused: bool, + /// The app closed the connection: what arrives is dropped. + closing: bool, reading: std::cell::Cell, waker: Option, close_wakers: Vec, @@ -455,38 +657,84 @@ struct HostState { task: std::rc::Weak>>>, } impl HostState { + /// Whether the unread messages fill their queue: reading pauses. + fn full(&self) -> bool { + self.messages.len() >= UNREAD || self.bytes >= LIMIT + } + + /// Ends the connection with `error`, as a transport event would. + fn fail(shared: &Rc>, error: JsError) { + Self::apply(shared, Err(error)); + } + fn event(shared: &Rc>, event: WebSocketEvent) { + Self::apply(shared, Ok(event)); + } + + fn apply(shared: &Rc>, event: Result) { let mut state = shared.borrow_mut(); - if state.error.is_some() { + if state.status.is_some() { return; } + let mut pause = false; match event { - WebSocketEvent::Open { .. } => state.open = true, - WebSocketEvent::Message(message) => { + Err(error) => state.end(CloseStatus::abnormal(), Some(error)), + Ok(WebSocketEvent::Open { .. }) => state.open = true, + Ok(WebSocketEvent::Message(_)) if state.closing => {} + Ok(WebSocketEvent::Message(message)) => { + // A paused transport may still deliver what was on its way; + // twice the queue bounds a transport that keeps delivering. if message.len() > LIMIT - || state.messages.len() >= UNREAD - || state.bytes.saturating_add(message.len()) > LIMIT + || state.messages.len() >= 2 * UNREAD + || state.bytes.saturating_add(message.len()) > 2 * LIMIT { - state.error = Some(format!( - "WebSocket closed: more than {UNREAD} messages or {LIMIT} bytes arrived unread" - )); + state.end( + CloseStatus::abnormal(), + Some(exceeded(format!( + "WebSocket closed: the transport kept delivering while {UNREAD} messages or {LIMIT} bytes waited unread" + ))), + ); } else { state.bytes += message.len(); state.messages.push_back(message); + if !state.paused && state.full() { + state.paused = true; + pause = true; + } } } - WebSocketEvent::Error(error) => state.error = Some(error), - WebSocketEvent::Close { .. } => state.error = Some("WebSocket closed by peer".into()), + // The browser reports no reason: the connection failed. + Ok(WebSocketEvent::Error(error)) => { + state.end(CloseStatus::abnormal(), Some(closed(error))) + } + Ok(WebSocketEvent::Close { + code, + reason, + was_clean, + }) => { + let error = (!was_clean).then(|| { + closed(format!( + "WebSocket connection ended without a close handshake ({code})" + )) + }); + state.end(CloseStatus { code, reason }, error); + } } - let task = if state.error.is_some() { + let task = if state.status.is_some() { state.held.take(); state.task.upgrade() } else { None }; + let paused = pause.then(|| state.task.upgrade()).flatten(); let waker = state.waker.take(); let close_wakers = std::mem::take(&mut state.close_wakers); drop(state); + if let Some(task) = paused + && let Some(task) = task.borrow_mut().as_mut() + { + task.pause(true); + } if let Some(task) = task { task.borrow_mut().take(); } @@ -497,10 +745,33 @@ impl HostState { waker.wake(); } } + + /// The connection ended: closed with `status`, failed with `error`. + fn end(&mut self, status: CloseStatus, error: Option) { + self.status = Some(status); + self.error = error; + } } #[cfg(test)] mod tests { + /// What `later` settles JS's promise with: its value as JSON (`null` + /// for `undefined` too), or its error's `code`. + async fn settled( + later: crate::element::Later, + ) -> Result> { + match later.into_future().await { + Ok(value) => Ok(crate::element::json_host::result(Ok(value)) + .unwrap() + .unwrap_or_default()), + Err(crate::element::Thrown::Fields(fields)) => Err(fields + .field("code") + .and_then(serde_json::Value::as_str) + .map(str::to_owned)), + Err(_) => Err(None), + } + } + use std::{cell::Cell, sync::Arc}; use super::*; @@ -512,6 +783,7 @@ mod tests { thread_local! { static EVENTS:RefCell>=RefCell::new(None); static DROPS:Cell=const {Cell::new(0)}; + static PAUSES:RefCell>=const {RefCell::new(Vec::new())}; } struct Browser; struct Task { @@ -532,6 +804,9 @@ mod tests { fn buffered_amount(&self) -> usize { self.buffered } + fn pause(&mut self, paused: bool) { + PAUSES.with(|pauses| pauses.borrow_mut().push(paused)); + } } impl WebSocketTransport for Browser { fn supports_headers(&self) -> bool { @@ -614,6 +889,47 @@ mod tests { EVENTS.with(|events| events.borrow_mut().take()); } + #[test] + fn a_host_refusing_connections_refuses_websockets_and_sockets() { + let refuse: crate::element::RefuseConnections = Arc::new(|target: &str| { + JsError::Error(format!("{target}: refused")).with_code("ERR_TEST_REFUSED") + }); + let host = system().with_refuse_connections(Some(refuse.clone())); + let Err(error) = WebSocket::connect("ws://example.com".into(), None, host.clone()) else { + panic!("a refused WebSocket connected") + }; + assert_eq!(error.code(), Some("ERR_TEST_REFUSED")); + assert_eq!(error.message(), "ws://example.com/: refused"); + assert_eq!(host.quotas.held(WEBSOCKETS), 0); + let sockets = serde_json::json!({ "network": { "hosts": ["*"], "sockets": ["[::1]:7"] } }); + let native = System { + access: Arc::new(Access { + declared: crate::access::Capabilities::from_json(&sockets).unwrap(), + granted: None, + }), + ..System::default() + } + .with_refuse_connections(Some(refuse)); + #[cfg(not(target_arch = "wasm32"))] + { + let Err(error) = WebSocket::connect("ws://example.com".into(), None, native.clone()) + else { + panic!("a refused WebSocket connected") + }; + assert_eq!(error.code(), Some("ERR_TEST_REFUSED")); + } + let options = super::super::net::ConnectOptions { + host: "::1".into(), + port: 7, + }; + let Err(error) = super::super::net::Net::connect(options, native.clone()) else { + panic!("a refused socket connected") + }; + assert_eq!(error.code(), Some("ERR_TEST_REFUSED")); + assert_eq!(error.message(), "[::1]:7: refused"); + assert_eq!(native.quotas.held("net.sockets"), 0); + } + #[test] fn host_checks_grants_headers_and_shared_quota_before_connecting() { let mut denied = system(); @@ -621,7 +937,10 @@ mod tests { declared: crate::access::Capabilities::default(), granted: None, }); - assert!(WebSocket::connect("ws://example.com".into(), None, denied).is_err()); + let Err(error) = WebSocket::connect("ws://example.com".into(), None, denied) else { + panic!("an ungranted URL connected") + }; + assert_eq!(error.code(), Some(codes::ACCESS_DENIED)); let sys = system(); let headers = ConnectOptions { headers: vec![("x-test".into(), "test".into())], @@ -631,7 +950,10 @@ mod tests { .quotas .reserve(WEBSOCKETS, MAX_WEBSOCKETS, MAX_WEBSOCKETS) .unwrap(); - assert!(WebSocket::connect("ws://example.com".into(), None, sys).is_err()); + let Err(error) = WebSocket::connect("ws://example.com".into(), None, sys) else { + panic!("the 33rd WebSocket connected") + }; + assert_eq!(error.code(), Some(codes::QUOTA_EXCEEDED)); } fn host(buffered: usize) -> WebSocketConnection { @@ -648,8 +970,19 @@ mod tests { } } + fn clean_close(state: &Rc>, code: u16, reason: &str) { + HostState::event( + state, + WebSocketEvent::Close { + code, + reason: reason.into(), + was_clean: true, + }, + ); + } + #[tokio::test] - async fn host_bounds_writes_and_reads_messages_before_close_error() { + async fn host_reads_what_came_before_a_clean_close_then_null() { let connection = host(LIMIT); assert!(connection.write(Data::Text("x".into())).is_err()); let Connection::Host(ref host) = connection.inner else { @@ -659,20 +992,47 @@ mod tests { &host.state, WebSocketEvent::Message(WebSocketData::Text("before close".into())), ); + assert_eq!(connection.close_code(), None); + clean_close(&host.state, 4000, "done"); + assert_eq!(connection.close_code(), Some(4000)); + assert_eq!(connection.close_reason().as_deref(), Some("done")); + assert_eq!( + settled(connection.read().unwrap()).await, + Ok(serde_json::json!("before close")) + ); + assert_eq!( + settled(connection.read().unwrap()).await, + Ok(serde_json::json!(null)) + ); + assert_eq!( + settled(connection.read().unwrap()).await, + Ok(serde_json::json!(null)) + ); + } + + #[tokio::test] + async fn host_rejects_reads_after_a_connection_that_ended_without_a_handshake() { + let connection = host(0); + let Connection::Host(ref host) = connection.inner else { + unreachable!() + }; HostState::event( &host.state, WebSocketEvent::Close { - code: 1000, + code: 1006, reason: String::new(), - was_clean: true, + was_clean: false, }, ); - assert!(connection.read().unwrap().into_future().await.is_ok()); - assert!(connection.read().unwrap().into_future().await.is_err()); + let error = settled(connection.read().unwrap()).await.unwrap_err(); + assert_eq!(error.as_deref(), Some("ERR_SOCKET_CLOSED")); + assert_eq!(connection.close_code(), Some(ABNORMAL)); + assert_eq!(connection.close_reason().as_deref(), Some("")); } - #[test] - fn host_unread_overflow_cancels_transport_and_outstanding_read_is_exclusive() { + #[tokio::test] + async fn host_pauses_the_transport_while_the_unread_queue_is_full() { + PAUSES.with(|pauses| pauses.borrow_mut().clear()); let connection = host(0); let reading = connection.read().unwrap(); assert!(connection.read().is_err()); @@ -680,29 +1040,96 @@ mod tests { let Connection::Host(ref host) = connection.inner else { unreachable!() }; - for _ in 0..=UNREAD { - HostState::event( - &host.state, - WebSocketEvent::Message(WebSocketData::Bytes(vec![1])), + let message = || WebSocketEvent::Message(WebSocketData::Bytes(vec![1])); + for _ in 0..UNREAD { + HostState::event(&host.state, message()); + } + PAUSES.with(|pauses| assert_eq!(*pauses.borrow(), [true])); + // What was on its way still arrives and is kept. + HostState::event(&host.state, message()); + assert!(host.task.borrow().is_some()); + assert!(host.state.borrow().error.is_none()); + assert_eq!(host.state.borrow().messages.len(), UNREAD + 1); + // Reading below the threshold resumes the transport. + for _ in 0..2 { + assert!( + settled(connection.read().unwrap()) + .await + .is_ok_and(|v| !v.is_null()) ); } + PAUSES.with(|pauses| assert_eq!(*pauses.borrow(), [true, false])); + // A transport that keeps delivering past twice the queue fails. + for _ in 0..=UNREAD + 1 { + HostState::event(&host.state, message()); + } assert!(host.task.borrow().is_none()); - assert_eq!(host.state.borrow().messages.len(), UNREAD); - assert!( - host.state - .borrow() - .error - .as_ref() + assert_eq!( + host.state.borrow().error.as_ref().unwrap().code(), + Some(codes::QUOTA_EXCEEDED) + ); + assert_eq!(connection.close_code(), Some(ABNORMAL)); + } + + #[tokio::test] + async fn host_close_sends_its_frame_and_drops_unread_messages() { + let connection = host(0); + let Connection::Host(ref host) = connection.inner else { + unreachable!() + }; + let state = host.state.clone(); + HostState::event( + &state, + WebSocketEvent::Message(WebSocketData::Text("unread".into())), + ); + let mut close = std::pin::pin!( + connection + .close(Some(4001.0), Some("bye".into())) .unwrap() - .contains("arrived unread") + .into_future() ); + assert!(poll(close.as_mut()).is_pending()); + // Arriving before the peer's reply: dropped. + HostState::event( + &state, + WebSocketEvent::Message(WebSocketData::Text("late".into())), + ); + clean_close(&state, 4001, "bye"); + assert!(matches!(poll(close.as_mut()), Poll::Ready(Ok(_)))); + assert_eq!( + settled(connection.read().unwrap()).await, + Ok(serde_json::json!(null)) + ); + assert_eq!(connection.close_code(), Some(4001)); + } + + #[test] + fn close_codes_and_reasons_are_checked() { + let requested = |code: Option, reason: &str| { + CloseStatus::requested(code, Some(reason.into())).map(|status| status.code) + }; + assert_eq!(requested(None, ""), Ok(1000)); + assert_eq!(requested(Some(1000.0), ""), Ok(1000)); + assert_eq!(requested(Some(3000.0), ""), Ok(3000)); + assert_eq!(requested(Some(4999.0), &"x".repeat(MAX_REASON)), Ok(4999)); + for code in [0.0, 999.0, 1001.0, 1005.0, 2999.0, 3000.5, 5000.0, f64::NAN] { + let error = requested(Some(code), "").unwrap_err(); + assert!( + matches!(&error, JsError::Fields(fields) if fields.class == crate::element::ErrorClass::Range), + "{code}" + ); + assert_eq!(error.code(), Some("ERR_OUT_OF_RANGE")); + } + // 124 bytes, as 62 two-byte characters. + let error = requested(None, &"é".repeat(62)).unwrap_err(); + assert_eq!(error.code(), Some("ERR_OUT_OF_RANGE")); } #[tokio::test] async fn operations_keep_transport_alive_after_connection_is_collected() { let connection = host(0); let read = connection.read().unwrap(); - let close = connection.close(); + let close = connection.close(None, None).unwrap(); let mut read = std::pin::pin!(read.into_future()); let mut close = std::pin::pin!(close.into_future()); assert!(poll(read.as_mut()).is_pending()); @@ -714,15 +1141,14 @@ mod tests { drop(connection); assert!(poll(read.as_mut()).is_pending()); assert!(poll(close.as_mut()).is_pending()); - HostState::event( - &state, - WebSocketEvent::Close { - code: 1000, - reason: String::new(), - was_clean: true, - }, + clean_close(&state, 1000, ""); + let Poll::Ready(Ok(read)) = poll(read.as_mut()) else { + panic!("the read is settled") + }; + assert_eq!( + crate::element::json_host::result(Ok(read)), + Ok(Some(serde_json::Value::Null)) ); - assert!(matches!(poll(read.as_mut()), Poll::Ready(Err(_)))); assert!(matches!(poll(close.as_mut()), Poll::Ready(Ok(_)))); } } diff --git a/third-party/nsh/crates/nsh-core/src/runtime/websocket/native.rs b/third-party/nsh/crates/nsh-core/src/runtime/websocket/native.rs index 21172a1a3d..afbb7843d8 100644 --- a/third-party/nsh/crates/nsh-core/src/runtime/websocket/native.rs +++ b/third-party/nsh/crates/nsh-core/src/runtime/websocket/native.rs @@ -1,12 +1,21 @@ -//! `websocket`: an explicit WebSocket client (`WebSocket.connect(url)`), -//! allowed by the HTTP grants a GET of its URL needs +//! `websocket`'s native connections: `connect(url)`, allowed by the HTTP +//! grants a GET of its URL needs //! ([`crate::access::Access::allows_websocket`]). Calls check their URL, -//! options, grant and limits in the JS call (a `TypeError` or `RangeError` -//! there); the handshake is a task of the core, and each connection's -//! socket is driven by a task of its own on the engine's tokio runtime, -//! which answers pings and keeps up to eight unread messages. +//! options, grant and limits in the JS call and reject at once (the codes +//! are the parent module's); the handshake is a task of the core, and each +//! connection's socket is driven by a task of its own on the engine's tokio +//! runtime. The task answers pings and keeps what arrives until it is read; +//! while 8 messages or 8 MiB wait unread it does not poll the socket, so +//! TCP's flow control holds the peer back (and pings wait) until the app +//! reads. -use std::{cell::Cell, collections::VecDeque, rc::Rc, sync::Arc, time::Duration}; +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + rc::Rc, + sync::Arc, + time::Duration, +}; use futures_util::{SinkExt, StreamExt}; use tokio::sync::{mpsc, oneshot}; @@ -14,31 +23,32 @@ use tokio_tungstenite::tungstenite::{ Message, client::IntoClientRequest, http::{HeaderName, HeaderValue}, - protocol::WebSocketConfig, + protocol::{CloseFrame, WebSocketConfig}, }; use super::{ - super::{MAX_WEBSOCKETS, WEBSOCKETS}, - ConnectOptions, Data, + CloseStatus, ConnectOptions, Data, busy, closed, denied, exceeded, slot, timed_out, too_long, }; -use crate::element::{Held, JsError, Later, Spawned, System, spawn}; +use crate::element::{Held, JsError, Later, Spawned, System, io_code, spawn}; /// The largest message one write sends or one read returns. const LIMIT: usize = 8 * 1024 * 1024; /// How long resolving, connecting with the handshake, a write and closing /// may take. const TIMEOUT: Duration = Duration::from_secs(30); -/// Messages received while no read waits; one more closes the connection. +/// Unread messages at which the socket is no longer polled. const UNREAD: usize = 8; -/// Bytes of messages received while no read waits; more closes the -/// connection. Native memory the heap limit does not cover. +/// Bytes of unread messages at which the socket is no longer polled; with +/// the message that crossed it, less than twice this is kept. Native +/// memory the heap limit does not cover. const UNREAD_BYTES: usize = LIMIT; /// Bytes of writes not sent yet, per connection (the global `WebSocket`'s -/// `bufferedAmount` limit); a write past it throws. +/// `bufferedAmount` limit); a write past it rejects. const PENDING_WRITES: usize = LIMIT; +/// The close code of a close frame without one (RFC 6455 7.1.5). +const NO_STATUS: u16 = 1005; -/// WebSocket client connections to the hosts the HTTP grants allow (native -/// hosts only: on the web, importing this module throws). +/// WebSocket client connections to the hosts the HTTP grants allow. pub(super) struct WebSocket; impl WebSocket { @@ -48,12 +58,12 @@ impl WebSocket { url: String, options: Option, sys: System, - ) -> Result>, JsError> { + ) -> Result>, JsError> { let parsed = url::Url::parse(&url) .map_err(|error| JsError::Type(format!("invalid WebSocket URL: {error}")))?; if !matches!(parsed.scheme(), "ws" | "wss") { return Err(JsError::Type( - "WebSocket.connect(url) expects a ws:// or wss:// URL".into(), + "connect(url) expects a ws:// or wss:// URL".into(), )); } // As the global `WebSocket`: the handshake would drop them silently. @@ -62,7 +72,7 @@ impl WebSocket { || parsed.password().is_some() { return Err(JsError::Type( - "WebSocket.connect(url) expects a URL without credentials or fragment".into(), + "connect(url) expects a URL without credentials or fragment".into(), )); } let host = parsed @@ -74,15 +84,10 @@ impl WebSocket { })?; // Checked on the parsed URL, which is the one the handshake uses. if !sys.access.allows_websocket(parsed.as_str()) { - return Err(JsError::Type(format!( - "network access to `{host}` is not granted; add it to capabilities.network.hosts or a GET rule in capabilities.network.http" - ))); + return Err(denied(parsed.as_str())); } - let held = sys.quotas.take(WEBSOCKETS, MAX_WEBSOCKETS).ok_or_else(|| { - JsError::Range(format!( - "At most {MAX_WEBSOCKETS} concurrent WebSockets are allowed per VM" - )) - })?; + sys.check_connection(parsed.as_str())?; + let held = slot(&sys)?; let headers = options .map(|o| o.headers) .unwrap_or_default() @@ -99,12 +104,12 @@ impl WebSocket { } } -type Reply = oneshot::Sender>; +type Reply = oneshot::Sender>; enum Command { - Read(Reply), + Read(Reply>), Write(Message, Reply<()>), - Close(Reply<()>), + Close(CloseStatus, Reply<()>), } /// An open WebSocket connection: one read, up to eight commands and 8 MiB @@ -114,6 +119,8 @@ pub(super) struct WebSocketConnection { reading: Rc>, /// Bytes of writes not sent yet. pending: Rc>, + /// How the connection closed, once it ended; the driver sets it. + status: Rc>>, /// The task driving the socket; dropping it closes the connection. _driver: Spawned<()>, } @@ -128,13 +135,11 @@ impl WebSocketConnection { }; let length = message.len(); if length > LIMIT { - return Err(JsError::Range(format!( - "WebSocket write exceeded the {LIMIT} byte limit" - ))); + return Err(too_long(length)); } let queued = self.pending.get() + length; if queued > PENDING_WRITES { - return Err(JsError::Range(format!( + return Err(exceeded(format!( "WebSocket writes waiting to be sent exceeded the {PENDING_WRITES} byte limit" ))); } @@ -154,12 +159,11 @@ impl WebSocketConnection { })) } - /// The next message: a string for text, a Uint8Array for bytes. - pub(super) fn read(&self) -> Result, JsError> { + /// The next message: a string for text, a Uint8Array for bytes; `null` + /// once the connection closed cleanly and every message was read. + pub(super) fn read(&self) -> Result>, JsError> { if self.reading.replace(true) { - return Err(JsError::Type( - "WebSocket.read() already has an outstanding read".into(), - )); + return Err(busy()); } struct Reading(Rc>); impl Drop for Reading { @@ -172,18 +176,28 @@ impl WebSocketConnection { let message = enqueue(&self.commands, Command::Read(send), receive); Ok(Later::new(async move { let _reading = reading; - Ok::<_, String>(match message.await? { + Ok::<_, JsError>(message.await?.map(|message| match message { Message::Text(text) => Data::Text(text.as_str().to_owned()), Message::Binary(bytes) => Data::Bytes(bytes.to_vec()), _ => unreachable!("the driver returns data messages only"), - }) + })) })) } - /// Sends a close frame and ends the connection. - pub(super) fn close(&self) -> Later<()> { + /// Sends a close frame with `frame`'s code and reason and waits for the + /// peer's reply. + pub(super) fn close(&self, frame: CloseStatus) -> Later<()> { let (send, receive) = oneshot::channel(); - Later::new(enqueue(&self.commands, Command::Close(send), receive)) + Later::new(enqueue( + &self.commands, + Command::Close(frame, send), + receive, + )) + } + + /// How the connection closed, once it did. + pub(super) fn status(&self) -> Option { + self.status.borrow().clone() } } @@ -192,19 +206,41 @@ impl WebSocketConnection { fn enqueue( commands: &mpsc::Sender, command: Command, - reply: oneshot::Receiver>, -) -> impl Future> + 'static { + reply: oneshot::Receiver>, +) -> impl Future> + 'static { let queued = commands.try_send(command).map_err(|error| match error { - mpsc::error::TrySendError::Full(_) => { - "WebSocket command queue is full; wait for an outstanding operation".to_owned() - } - mpsc::error::TrySendError::Closed(_) => "WebSocket connection is closed".to_owned(), + mpsc::error::TrySendError::Full(_) => exceeded( + "WebSocket command queue is full; wait for an outstanding operation".to_owned(), + ), + mpsc::error::TrySendError::Closed(_) => closed("WebSocket connection is closed"), }); async move { queued?; reply .await - .map_err(|_| "WebSocket connection is closed".to_owned())? + .map_err(|_| closed("WebSocket connection is closed"))? + } +} + +/// A failure of the socket or the protocol, `context` first in its +/// message: the OS's code for an I/O error (`ERR_SOCKET_CLOSED` for one +/// without), `ERR_SOCKET_CLOSED` for a closed connection, `EMSGSIZE` for a message past the limit, `EPROTO` +/// (and the HTTP `status` of a refused handshake) for the rest. +fn failure(context: &str, error: tokio_tungstenite::tungstenite::Error) -> JsError { + use tokio_tungstenite::tungstenite::Error as E; + let message = format!("{context}: {error}"); + match error { + E::Io(ref io) => match io_code(io) { + Some(code) => JsError::Error(message).with_code(code), + None => closed(message), + }, + E::ConnectionClosed | E::AlreadyClosed => closed(message), + E::Capacity(_) => JsError::Error(message).with_code("EMSGSIZE"), + E::WriteBufferFull(_) => exceeded(message), + E::Http(ref response) => JsError::Error(message) + .with_code("EPROTO") + .with("status", response.status().as_u16()), + _ => JsError::Error(message).with_code("EPROTO"), } } @@ -218,11 +254,11 @@ async fn open( port: u16, headers: Vec<(HeaderName, HeaderValue)>, held: Held, -) -> Result { +) -> Result { let mut request = url .as_str() .into_client_request() - .map_err(|error| format!("WebSocket handshake failed: {error}"))?; + .map_err(|error| failure("WebSocket handshake failed", error))?; request.headers_mut().extend(headers); request .headers_mut() @@ -239,28 +275,44 @@ async fn open( let addresses = tokio::time::timeout_at(deadline, tokio::net::lookup_host((host.as_str(), port))) .await - .map_err(|_| format!("WebSocket connection failed: resolving {operation} timed out"))? + .map_err(|_| { + timed_out(&format!( + "WebSocket connection failed: resolving {operation}" + )) + })? + // A failed lookup names no OS error: Node calls it ENOTFOUND. .map_err(|error| { - format!("WebSocket connection failed: resolving {operation} failed: {error}") + JsError::Error(format!( + "WebSocket connection failed: resolving {operation} failed: {error}" + )) + .with_code("ENOTFOUND") })?; let mut connected = None; - let mut last_error = "host resolved to no addresses".to_owned(); + let mut last_error = None; for address in addresses { match tokio::time::timeout_at(deadline, tokio::net::TcpStream::connect(address)).await { Ok(Ok(stream)) => { connected = Some(stream); break; } - Ok(Err(error)) => last_error = error.to_string(), + Ok(Err(error)) => last_error = Some(error), Err(_) => { - return Err(format!( - "WebSocket connection failed: connecting to {operation} timed out" - )); + return Err(timed_out(&format!( + "WebSocket connection failed: connecting to {operation}" + ))); } } } let stream = connected.ok_or_else(|| { - format!("WebSocket connection failed: connecting to {operation} failed: {last_error}") + let message = format!( + "WebSocket connection failed: connecting to {operation} failed: {}", + last_error + .as_ref() + .map(|error| error.to_string()) + .unwrap_or_else(|| "host resolved to no addresses".into()) + ); + let code = last_error.as_ref().and_then(io_code).unwrap_or("ENOTFOUND"); + JsError::Error(message).with_code(code) })?; let connector = if url.scheme() == "wss" { Some(tokio_tungstenite::Connector::Rustls( @@ -274,121 +326,175 @@ async fn open( tokio_tungstenite::client_async_tls_with_config(request, stream, Some(config), connector), ) .await - .map_err(|_| "WebSocket handshake timed out".to_owned())? - .map_err(|error| format!("WebSocket handshake failed: {error}"))?; + .map_err(|_| timed_out("WebSocket handshake"))? + .map_err(|error| failure("WebSocket handshake failed", error))?; let (commands, receiver) = mpsc::channel(UNREAD); - let driver = spawn(drive(socket, receiver, held)); + let status = Rc::>>::default(); + let driver = spawn(drive(socket, receiver, held, status.clone())); Ok(WebSocketConnection { commands, reading: Rc::default(), pending: Rc::default(), + status, _driver: driver, }) } /// Respect the embedding host's provider; otherwise ring is local to this /// connection. Selecting a fallback must not configure the host's other TLS clients. -fn tls_config() -> Result { +fn tls_config() -> Result { let provider = rustls::crypto::CryptoProvider::get_default() .cloned() .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider())); let roots = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); rustls::ClientConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() - .map_err(|error| format!("WebSocket TLS configuration failed: {error}")) + .map_err(|error| JsError::Error(format!("WebSocket TLS configuration failed: {error}"))) .map(|builder| builder.with_root_certificates(roots).with_no_client_auth()) } +/// What a close frame says: its code and reason, 1005 for a frame +/// without a code. +fn received(frame: Option) -> CloseStatus { + frame.map_or( + CloseStatus { + code: NO_STATUS, + reason: String::new(), + }, + |frame| CloseStatus { + code: frame.code.into(), + reason: frame.reason.as_str().to_owned(), + }, + ) +} + +/// The close handshake the app starts: its close frame, then the peer's +/// reply, which tells how the connection closed (1006 when the peer ended +/// it without one). What arrives before the reply is dropped: the app +/// reads no more. +async fn handshake(socket: &mut Socket, frame: CloseStatus) -> Result { + let frame = CloseFrame { + code: frame.code.into(), + reason: frame.reason.into(), + }; + socket + .close(Some(frame)) + .await + .map_err(|error| failure("WebSocket close failed", error))?; + loop { + match socket.next().await { + Some(Ok(Message::Close(frame))) => return Ok(received(frame)), + Some(Ok(_)) => {} + Some(Err(_)) | None => return Ok(CloseStatus::abnormal()), + } + } +} + /// Runs the connection's commands and keeps what arrives until it is read. -/// Once the peer ends the connection, the messages that came before are -/// still read, in order, before the reason it ended; the VM's place for -/// the connection is given back then. -async fn drive(mut socket: Socket, mut commands: mpsc::Receiver, held: Held) { +/// While [`UNREAD`] messages or [`UNREAD_BYTES`] wait, the socket is not +/// polled: TCP holds the peer back until a read takes one. Once the +/// connection ends, the messages that came before are still read, in +/// order; then a clean close reads `null` and a failure rejects with why it +/// ended. The VM's place for the connection is given back then. +async fn drive( + mut socket: Socket, + mut commands: mpsc::Receiver, + held: Held, + status: Rc>>, +) { let mut messages: VecDeque = VecDeque::new(); let mut unread = 0; - let mut read: Option> = None; - let error = loop { + let mut read: Option>> = None; + // How it ended, and the error reads reject with (none for a clean close). + let (ended, error) = loop { + let room = messages.len() < UNREAD && unread < UNREAD_BYTES; tokio::select! { biased; command = commands.recv() => match command { None => { messages.clear(); - break "WebSocket connection is closed".to_owned(); + break (CloseStatus::abnormal(), Some(closed("WebSocket connection is closed"))); } Some(Command::Read(reply)) => match messages.pop_front() { Some(message) => { unread -= message.len(); - let _ = reply.send(Ok(message)); + let _ = reply.send(Ok(Some(message))); } None => read = Some(reply), }, Some(Command::Write(message, reply)) => { let result = tokio::time::timeout(TIMEOUT, socket.send(message)) .await - .map_err(|_| "WebSocket write timed out".to_owned()) - .and_then(|result| result.map_err(|error| format!("WebSocket write failed: {error}"))); + .map_err(|_| timed_out("WebSocket write")) + .and_then(|result| result.map_err(|error| failure("WebSocket write failed", error))); let failure = result.as_ref().err().cloned(); let _ = reply.send(result); if let Some(error) = failure { - break error; + break (CloseStatus::abnormal(), Some(error)); } } - Some(Command::Close(reply)) => { - let result = tokio::time::timeout(TIMEOUT, socket.close(None)) - .await - .map_err(|_| "WebSocket close timed out".to_owned()) - .and_then(|result| result.map_err(|error| format!("WebSocket close failed: {error}"))); - let _ = reply.send(result); + Some(Command::Close(frame, reply)) => { // Closed by the app: what was not read goes with it. messages.clear(); - break "WebSocket connection is closed".to_owned(); + let result = tokio::time::timeout(TIMEOUT, handshake(&mut socket, frame)) + .await + .map_err(|_| timed_out("WebSocket close")) + .and_then(|result| result); + let ended = result.as_ref().map_or_else(|_| CloseStatus::abnormal(), Clone::clone); + // Set before the reply: `closeCode` is there once + // `close()` resolves. + *status.borrow_mut() = Some(ended.clone()); + let _ = reply.send(result.map(drop)); + break (ended, None); } }, - incoming = socket.next() => match incoming { + incoming = socket.next(), if room => match incoming { Some(Ok(message @ (Message::Text(_) | Message::Binary(_)))) => { if let Some(reply) = read.take() { - let _ = reply.send(Ok(message)); - } else if messages.len() < UNREAD && unread + message.len() <= UNREAD_BYTES { + let _ = reply.send(Ok(Some(message))); + } else { unread += message.len(); messages.push_back(message); - } else { - // The app reads slower than the peer sends: closing - // bounds the memory instead of buffering without end. - let _ = tokio::time::timeout(TIMEOUT, socket.close(None)).await; - break format!( - "WebSocket closed: more than {UNREAD} messages or {UNREAD_BYTES} bytes arrived unread" - ); } } Some(Ok(Message::Ping(_))) => { if let Err(error) = socket.flush().await { - break format!("WebSocket pong failed: {error}"); + break (CloseStatus::abnormal(), Some(failure("WebSocket pong failed", error))); } } - Some(Ok(Message::Close(_))) => { + Some(Ok(Message::Close(frame))) => { + // Sends the reply tungstenite queued. let _ = socket.flush().await; - break "WebSocket closed by peer".to_owned(); + break (received(frame), None); } Some(Ok(_)) => {} - Some(Err(error)) => break format!("WebSocket read failed: {error}"), - None => break "WebSocket connection is closed".to_owned(), + Some(Err(error)) => break (CloseStatus::abnormal(), Some(failure("WebSocket read failed", error))), + None => break ( + CloseStatus::abnormal(), + Some(closed("WebSocket connection ended without a close handshake")), + ), } } }; + *status.borrow_mut() = Some(ended); + let next = |messages: &mut VecDeque| match messages.pop_front() { + Some(message) => Ok(Some(message)), + None => error.clone().map_or(Ok(None), Err), + }; if let Some(reply) = read { - let _ = reply.send(Err(error.clone())); + let _ = reply.send(next(&mut messages)); } drop(socket); drop(held); while let Some(command) = commands.recv().await { match command { Command::Read(reply) => { - let _ = reply.send(messages.pop_front().ok_or_else(|| error.clone())); + let _ = reply.send(next(&mut messages)); } Command::Write(_, reply) => { - let _ = reply.send(Err("WebSocket connection is closed".to_owned())); + let _ = reply.send(Err(closed("WebSocket connection is closed"))); } - Command::Close(reply) => { + Command::Close(_, reply) => { messages.clear(); let _ = reply.send(Ok(())); } @@ -441,7 +547,7 @@ mod tests { .unwrap() .await; assert!( - matches!(result, Err(ref error) if error.contains("WebSocket handshake failed")) + matches!(result, Err(ref error) if error.message().contains("WebSocket handshake failed")) ); peer.join().unwrap(); assert!( diff --git a/third-party/nsh/crates/nsh-core/src/selection.rs b/third-party/nsh/crates/nsh-core/src/selection.rs index ad2643ece5..b1a976af68 100644 --- a/third-party/nsh/crates/nsh-core/src/selection.rs +++ b/third-party/nsh/crates/nsh-core/src/selection.rs @@ -402,6 +402,105 @@ impl SelectionModel { /// U+FFFC, which stands for an attachment in a text's content. const OBJECT_REPLACEMENT: char = '\u{FFFC}'; +/// The order of the texts a backend listed child by child, in mount order: +/// `runs` are the children's frames (in their parent) and the ranges of +/// texts each added. The children with texts go by their tops when they +/// stack (no two share a row): a virtual list's cells, which recycling +/// leaves in mount order rather than their items'. Otherwise (a row, an +/// overlay) mount order stands. Children without texts never count: a +/// spare cell left where it was may overlap the shown ones. `None` when +/// the order stays; else the texts' indices in their new order, from the +/// first run's start. +pub fn reading_order(runs: &[(Bounds, Range)]) -> Option> { + let runs: Vec<_> = runs.iter().filter(|(_, r)| !r.is_empty()).collect(); + let stacked = runs.iter().enumerate().all(|(i, (a, _))| { + runs[i + 1..] + .iter() + .all(|(b, _)| a.bottom() <= b.top() || b.bottom() <= a.top()) + }); + let mut order: Vec = (0..runs.len()).collect(); + if stacked { + order.sort_by(|&a, &b| { + runs[a] + .0 + .top() + .partial_cmp(&runs[b].0.top()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + if order.iter().enumerate().all(|(i, &o)| i == o) { + return None; + } + Some(order.into_iter().flat_map(|i| runs[i].1.clone()).collect()) +} + +/// Puts `items[start..]` in `order` ([`reading_order`]'s indices). +pub fn reorder(items: &mut Vec, start: usize, order: &[usize]) { + let mut taken: Vec> = items.drain(start..).map(Some).collect(); + items.extend(order.iter().filter_map(|&i| taken[i - start].take())); +} + +/// The virtual list rows a selection keeps rendered: the nodes of the +/// texts at its ends, which the backend hands the engine +/// (`EngineControl::keep_rendered`) so that scrolling away does not +/// recycle them. Tracks what was last handed over. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct KeptRows(Vec); + +impl KeptRows { + /// The nodes to keep when they changed since the last call (none: the + /// selection is gone), else `None`. + pub fn changed(&mut self, model: &SelectionModel) -> Option> { + let mut keys: Vec = if model.anchor.is_some() { + model.keys.iter().flatten().copied().collect() + } else { + Vec::new() + }; + keys.sort_unstable(); + keys.dedup(); + (keys != self.0).then(|| { + self.0 = keys.clone(); + keys + }) + } + /// Lets go of every row (the selection's area is gone): an empty list + /// when some were kept, else `None`. + pub fn release(&mut self) -> Option> { + (!self.0.is_empty()).then(|| { + self.0.clear(); + Vec::new() + }) + } +} + +/// How far a selection drag scrolls a scroll view per 16 ms tick (points, +/// negative toward its start) with the pointer `at` along the view's axis +/// (its coordinates; `extent` long), 0 away from the edges. It starts +/// [`AUTOSCROLL_INSIDE`] inside an edge, as a maximized window leaves the +/// pointer little room past it, and speeds up linearly from +/// [`AUTOSCROLL_MIN`] to [`AUTOSCROLL_MAX`] over [`AUTOSCROLL_RAMP`] from +/// there outward. +pub fn autoscroll_step(at: Points, extent: Points) -> Points { + let into = if at < AUTOSCROLL_INSIDE { + at - AUTOSCROLL_INSIDE + } else if at > extent - AUTOSCROLL_INSIDE { + at - (extent - AUTOSCROLL_INSIDE) + } else { + return Points(0.0); + }; + let t = (into.0.abs() / AUTOSCROLL_RAMP.0).min(1.0); + Points(into.0.signum() * (AUTOSCROLL_MIN.0 + t * (AUTOSCROLL_MAX.0 - AUTOSCROLL_MIN.0))) +} + +/// How far inside an edge a selection drag starts scrolling ([`autoscroll_step`]). +pub const AUTOSCROLL_INSIDE: Points = Points(16.0); +/// Its step per tick where it starts. +pub const AUTOSCROLL_MIN: Points = Points(12.0); +/// Its largest step per tick, [`AUTOSCROLL_RAMP`] from where it starts on. +pub const AUTOSCROLL_MAX: Points = Points(64.0); +/// How far from where it starts it reaches [`AUTOSCROLL_MAX`]. +pub const AUTOSCROLL_RAMP: Points = Points(96.0); + /// The position under `point` ([module docs](self): the hit-testing rule), /// `None` without texts. fn hit(texts: &impl SelectionTexts, point: Point) -> Option { @@ -1033,4 +1132,65 @@ mod tests { assert!(model.texts_changed(&gone)); assert!(model.bounds().is_none()); } + + #[test] + fn stacked_children_go_by_their_tops_and_rows_stay_in_mount_order() { + let frame = |x: f32, y: f32, h: f32| bounds(point(px(x), px(y)), size(px(100.0), px(h))); + // Recycled cells: mounted showing rows 2, 0, 1 (two texts in the + // first), and an empty spare cell overlapping them. + let runs = [ + (frame(0.0, 200.0, 100.0), 0..2), + (frame(0.0, 0.0, 300.0), 2..2), + (frame(0.0, 0.0, 100.0), 2..3), + (frame(0.0, 100.0, 100.0), 3..4), + ]; + let order = reading_order(&runs).unwrap(); + assert_eq!(order, [2, 3, 0, 1]); + let mut items = vec!["x", "c1", "c2", "a", "b"]; + let shifted: Vec = order.iter().map(|i| i + 1).collect(); + reorder(&mut items, 1, &shifted); + assert_eq!(items, ["x", "a", "b", "c1", "c2"]); + // Already in order, a row, an overlay: mount order. + let in_order = [ + (frame(0.0, 0.0, 10.0), 0..1), + (frame(0.0, 10.0, 10.0), 1..2), + ]; + assert_eq!(reading_order(&in_order), None); + let row = [ + (frame(200.0, 0.0, 20.0), 0..1), + (frame(0.0, 0.0, 20.0), 1..2), + ]; + assert_eq!(reading_order(&row), None); + let overlay = [ + (frame(0.0, 50.0, 100.0), 0..1), + (frame(0.0, 0.0, 100.0), 1..2), + ]; + assert_eq!(reading_order(&overlay), None); + } + + #[test] + fn kept_rows_follow_the_ends_texts() { + let texts = column(); + let mut model = SelectionModel::new(); + let mut kept = KeptRows::default(); + assert_eq!(kept.changed(&model), None); + model.press(&texts, at(4.0, 4.0), 1, false); + model.drag(&texts, at(4.0, 64.0)); + assert!(!kept.changed(&model).unwrap().is_empty()); + assert_eq!(kept.changed(&model), None); + model.clear(); + assert_eq!(kept.changed(&model), Some(Vec::new())); + } + + #[test] + fn autoscroll_starts_inside_the_edge_and_speeds_up_outward() { + let step = |at: f32| autoscroll_step(Points(at), Points(400.0)); + assert_eq!(step(200.0), Points(0.0)); + assert_eq!(step(17.0), Points(0.0)); + assert!(step(15.0) < Points(0.0)); + assert!(step(385.0) > Points(0.0)); + assert_eq!(step(400.0 - 16.0 + 96.0), AUTOSCROLL_MAX); + assert_eq!(step(-500.0), Points(-AUTOSCROLL_MAX.0)); + assert!(step(420.0) > step(390.0)); + } } diff --git a/third-party/nsh/crates/nsh-core/src/shader.rs b/third-party/nsh/crates/nsh-core/src/shader.rs index 83fcb6ea0f..770a5edb9e 100644 --- a/third-party/nsh/crates/nsh-core/src/shader.rs +++ b/third-party/nsh/crates/nsh-core/src/shader.rs @@ -27,7 +27,7 @@ //! //! The native backends render the module with wgpu (`nsh_engine::shader`), the web //! backend with WebGL 2 from the GLSL ES this module translates it into -//! ([`Shader::glsl_es`], feature `glsl`). +//! (`Shader::glsl_es`, feature `glsl`). //! //! Animated values among the uniforms ([`UniformValue::Animated`]) become //! the layer's value slots ([`ShaderLayer::slots`]), bound to the view as @@ -273,6 +273,42 @@ pub fn source_hash(source: &str) -> u64 { hasher.finish() } +/// What compiles the apps' shader sources in this module ([`Shader::compile`], +/// which links naga). +pub type Compile = fn(&str) -> Result; + +#[cfg(target_arch = "wasm32")] +thread_local! { + /// The compiler a web runtime linked: one built for an app that uses no + /// shader leaves it out, naga with it ([`set_compile`]). + static COMPILE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Sets the compiler of this WebAssembly module's shader sources: the web +/// runtime's gate for shaders sets it, so naga is linked only into the +/// runtimes of apps that use shaders. The native backends always have +/// [`Shader::compile`]. +#[cfg(target_arch = "wasm32")] +pub fn set_compile(compile: Compile) { + COMPILE.with(|c| c.set(Some(compile))); +} + +/// Compiles `source` with this module's compiler, if it has one. +pub(crate) fn compile_source(source: &str) -> Result { + #[cfg(target_arch = "wasm32")] + { + match COMPILE.with(|c| c.get()) { + Some(compile) => compile(source), + None => Err(ShaderError::new( + "this web runtime has no shader compiler (it was linked for an app that \ + imports no `Shader`)", + )), + } + } + #[cfg(not(target_arch = "wasm32"))] + Shader::compile(source) +} + impl Shader { /// Checks and compiles the app's WGSL `source`, which defines /// `fn shade(uv: vec2f) -> vec4f` (module docs). diff --git a/third-party/nsh/crates/nsh-core/src/style.rs b/third-party/nsh/crates/nsh-core/src/style.rs index da5249b324..1580398087 100644 --- a/third-party/nsh/crates/nsh-core/src/style.rs +++ b/third-party/nsh/crates/nsh-core/src/style.rs @@ -1303,6 +1303,14 @@ pub(crate) fn resolve_normal_alignment(layout: &mut taffy::Style) { } } +/// The layout style of a view that nothing styles: React Native's defaults +/// ([`Style::default`]'s) with `alignContent` resolved. +pub(crate) fn unstyled_layout() -> taffy::Style { + let mut layout = Style::default().layout; + resolve_normal_alignment(&mut layout); + layout +} + /// Lays a node out in direction `rtl`: its left and right properties keep /// their sides, as in CSS, and its start / end ones take the sides the /// direction gives them. @@ -1469,8 +1477,8 @@ style_keys! { FlexShrink = "flexShrink": Layout "number", FlexBasis = "flexBasis": Layout "DimensionValue | \"min-content\" | \"max-content\" | \"fit-content\"", JustifyContent = "justifyContent": Layout "Exclude | \"stretch\"", - AlignItems = "alignItems": Layout "LayoutAlign", - AlignSelf = "alignSelf": Layout "LayoutAlign | \"auto\"", + AlignItems = "alignItems": Layout "ItemAlign", + AlignSelf = "alignSelf": Layout "ItemAlign | \"auto\"", AlignContent = "alignContent": Layout "Distribute", Width = "width": Layout "DimensionValue | \"min-content\" | \"max-content\" | \"fit-content\"", Height = "height": Layout "DimensionValue | \"min-content\" | \"max-content\" | \"fit-content\"", @@ -1542,8 +1550,8 @@ style_keys! { GridRow = "gridRow": Layout "number | string", GridRowStart = "gridRowStart": Layout "number | string", GridRowEnd = "gridRowEnd": Layout "number | string", - JustifyItems = "justifyItems": Layout "LayoutAlign" "How grid items sit in their areas along the row.", - JustifySelf = "justifySelf": Layout "LayoutAlign | \"auto\"", + JustifyItems = "justifyItems": Layout "ItemAlign" "How grid items sit in their areas along the row.", + JustifySelf = "justifySelf": Layout "ItemAlign | \"auto\"", Direction = "direction": Layout "\"inherit\" | \"ltr\" | \"rtl\"" "Layout direction; inherited by default. As in CSS, `left` and `right` properties keep their sides right to left, while the `inlineStart` / `inlineEnd` ones (`marginStart`, `end`, ...) take the sides the direction gives them.", MarginStart = "marginStart": Layout "DimensionValue", MarginEnd = "marginEnd": Layout "DimensionValue", diff --git a/third-party/nsh/crates/nsh-core/src/svg/drawing.rs b/third-party/nsh/crates/nsh-core/src/svg/drawing.rs new file mode 100644 index 0000000000..8d8b79dfcd --- /dev/null +++ b/third-party/nsh/crates/nsh-core/src/svg/drawing.rs @@ -0,0 +1,543 @@ +//! SVGs as canvas drawings ([`drawing`]): what `element::svg` shows. The +//! document is parsed with usvg as the rasters are, and its tree is +//! recorded as the canvas's vector operations, which every platform +//! replays at its device pixels: an SVG stays sharp at any size and scale. +//! +//! The paints that were `currentColor` take the canvas view's text color +//! when it is drawn ([`ViewPaint::FillCurrentColor`]): usvg resolves +//! `currentColor` while it parses, so the root gets a `color` of a color +//! the document does not write (where it sets none itself), and the paints +//! of that color are the ones that inherited it. +//! +//! What a canvas has no operation for is drawn as near as it can be: a +//! group's opacity multiplies its shapes' (exact where they do not +//! overlap), its blend mode is each shape's composite operation, a clip +//! path of several shapes clips to them as one path (by `nonzero`), +//! gradients pad (`spreadMethod` aside), a gradient stop of `currentColor` +//! is black, and a radial gradient stretched by its transform strokes with +//! its circle. Masks and filters are left out (what they apply to is drawn +//! as it is), a pattern paints nothing, and `` and `` are not +//! drawn, as in the rasters. + +use std::sync::Arc; + +use resvg::usvg::{self, tiny_skia_path}; + +use super::Error; +use crate::{ + canvas::{ + CanvasGradient, CanvasOp, Composite, Drawing, FillRule, GradientShape, LineCap, LineJoin, + Num, Paint, Path, PathOp, ViewBoxFit, ViewPaint, + }, + geometry::{Bounds, Percentage, Point, Points, Size, point, px}, + style::Color, +}; + +/// The most operations a drawing of an SVG may hold: shared clip paths +/// are recorded each time they are used, so a small document could +/// otherwise make an enormous drawing. +const MAX_OPS: usize = 1 << 20; + +/// Colors the root's `color` may get to find the paints that inherit it +/// (`currentColor`): the first one the document does not write. +const SENTINELS: [(u8, u8, u8); 4] = [ + (0x01, 0xfe, 0x7c), + (0xfd, 0x02, 0x9b), + (0x7d, 0x03, 0xfc), + (0x02, 0x7e, 0xfb), +]; + +/// The SVG document `source` as a drawing in the coordinates of its +/// `viewBox`, fitted into the canvas's box as `preserveAspectRatio` says +/// ([`ViewPaint::Fit`]; an SVG without a `viewBox` scales as an image of +/// its size does): the canvas lays out at the SVG's size where its style +/// sets none. Its `currentColor` is the canvas's text color. +pub fn drawing(source: &str) -> Result { + super::guarded(|| { + if source.len() > super::MAX_BYTES { + return Err(Error::TooLarge); + } + let doc = super::xml(source)?; + let root = doc.root_element(); + let lower = source.to_ascii_lowercase(); + let sentinel = SENTINELS + .into_iter() + .find(|&(r, g, b)| !lower.contains(&format!("{r:02x}{g:02x}{b:02x}"))) + .unwrap_or(SENTINELS[0]); + let (r, g, b) = sentinel; + // A root `color` of the document's own is what `currentColor` + // inherits: nothing follows the view then. + let marked; + let text = if root.attribute("color").is_none() { + let at = super::root_attributes_at(&doc, source); + marked = format!( + "{} color=\"#{r:02x}{g:02x}{b:02x}\"{}", + &source[..at], + &source[at..] + ); + &marked[..] + } else { + source + }; + let tree = super::parse_with(text.as_bytes(), &super::options())?; + let size = tree.size(); + let natural = Size::new(px(size.width()), px(size.height())); + let view_box = + view_box(root.attribute("viewBox")).unwrap_or(Bounds::new(Point::default(), natural)); + let (align, slice) = aspect_ratio(root.attribute("preserveAspectRatio")); + let fit = ViewBoxFit { + view_box, + align, + slice, + size: natural, + }; + // usvg maps the view box onto the SVG's size; the drawing stays in + // the view box's coordinates, mapped onto the canvas's box. + let [sx, ky, kx, sy, tx, ty] = fit.transform(natural); + let into_size = tiny_skia_path::Transform::from_row(sx, ky, kx, sy, tx, ty); + let mut out = Recorder { + drawing: Drawing::default(), + current: usvg::Color::new_rgb(r, g, b), + }; + out.view_paint( + ViewPaint::Fit(fit), + CanvasOp::Transform([Num::Const(0.0); 6]), + ); + if let Some(back) = into_size.invert() { + out.transform(back); + } + out.group(tree.root(), 1.0)?; + Ok(out.drawing) + }) +} + +/// A `viewBox` of a positive width and height. +fn view_box(value: Option<&str>) -> Option> { + let numbers: Vec = value? + .split(|c: char| c.is_ascii_whitespace() || c == ',') + .filter(|s| !s.is_empty()) + .map(str::parse) + .collect::>() + .ok()?; + let [x, y, w, h] = numbers[..] else { + return None; + }; + (w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()) + .then(|| Bounds::new(point(px(x), px(y)), Size::new(px(w), px(h)))) +} + +/// `preserveAspectRatio`: where the view box sits along each axis (`None` +/// for `none`) and whether it slices; `xMidYMid meet` by default. +fn aspect_ratio(value: Option<&str>) -> (Option<(Percentage, Percentage)>, bool) { + let mut words = value + .unwrap_or_default() + .split_ascii_whitespace() + .skip_while(|&w| w == "defer"); + let at = |s: &str| match s { + "Min" => Some(Percentage(0.0)), + "Mid" => Some(Percentage(0.5)), + "Max" => Some(Percentage(1.0)), + _ => None, + }; + let align = match words.next() { + Some("none") => None, + Some(w) if w.len() == 8 && w.starts_with('x') && w.as_bytes()[4] == b'Y' => { + match (at(&w[1..4]), at(&w[5..8])) { + (Some(x), Some(y)) => Some((x, y)), + _ => Some((Percentage(0.5), Percentage(0.5))), + } + } + _ => Some((Percentage(0.5), Percentage(0.5))), + }; + (align, words.next() == Some("slice")) +} + +/// Records usvg's tree as canvas operations. +struct Recorder { + drawing: Drawing, + /// The root's `color` (`currentColor`). + current: usvg::Color, +} + +impl Recorder { + fn op(&mut self, op: CanvasOp) { + self.drawing.ops.push(op); + } + + fn view_paint(&mut self, view: ViewPaint, placeholder: CanvasOp) { + let at = self.drawing.ops.len() as u32; + self.drawing.view_paints.push((at, view)); + self.op(placeholder); + } + + fn transform(&mut self, ts: tiny_skia_path::Transform) { + if !ts.is_identity() { + self.op(CanvasOp::Transform( + [ts.sx, ts.ky, ts.kx, ts.sy, ts.tx, ts.ty].map(Num::Const), + )); + } + } + + fn group(&mut self, group: &usvg::Group, alpha: f32) -> Result<(), Error> { + let alpha = alpha * group.opacity().get(); + if alpha <= 0.0 { + return Ok(()); + } + if self.drawing.ops.len() > MAX_OPS { + return Err(Error::TooManyNodes); + } + if group.mask().is_some() { + log::warn!("svg: a mask is not drawn; what it masks is drawn whole"); + } + if !group.filters().is_empty() { + log::warn!("svg: a filter is not drawn; what it filters is drawn unfiltered"); + } + self.op(CanvasOp::Save); + self.transform(group.transform()); + if let Some(composite) = composite(group.blend_mode()) { + self.op(CanvasOp::Composite(composite)); + } + if let Some(clip) = group.clip_path() { + self.clip(clip); + } + for node in group.children() { + match node { + usvg::Node::Group(g) => self.group(g, alpha)?, + usvg::Node::Path(p) => self.path(p, alpha), + // No fonts or images are loaded. + usvg::Node::Image(_) | usvg::Node::Text(_) => {} + } + } + self.op(CanvasOp::Restore); + Ok(()) + } + + /// Clips to `clip` (and the clip path it is clipped by), its shapes as + /// one path. + fn clip(&mut self, clip: &usvg::ClipPath) { + if let Some(inner) = clip.clip_path() { + self.clip(inner); + } + let mut ops = Vec::new(); + let mut rules = Vec::new(); + collect_clip(clip.root(), clip.transform(), &mut ops, &mut rules); + let rule = match rules[..] { + [rule] => rule, + _ => FillRule::NonZero, + }; + self.op(CanvasOp::Clip(Some(ops.into()), rule)); + } + + fn path(&mut self, path: &usvg::Path, alpha: f32) { + if !path.is_visible() { + return; + } + let data = path_ops(path.data()); + let fill = |this: &mut Self| { + if let Some(fill) = path.fill() { + let rule = fill_rule(fill.rule()); + let alpha = alpha * fill.opacity().get(); + this.paint(fill.paint(), alpha, Target::Fill(rule), path.data(), &data); + } + }; + let stroke = |this: &mut Self| { + if let Some(stroke) = path.stroke() { + this.line_style(stroke); + let alpha = alpha * stroke.opacity().get(); + this.paint(stroke.paint(), alpha, Target::Stroke, path.data(), &data); + } + }; + match path.paint_order() { + usvg::PaintOrder::FillAndStroke => { + fill(self); + stroke(self); + } + usvg::PaintOrder::StrokeAndFill => { + stroke(self); + fill(self); + } + } + } + + fn line_style(&mut self, stroke: &usvg::Stroke) { + self.op(CanvasOp::LineWidth(Num::Const(stroke.width().get()))); + self.op(CanvasOp::LineCap(match stroke.linecap() { + usvg::LineCap::Butt => LineCap::Butt, + usvg::LineCap::Round => LineCap::Round, + usvg::LineCap::Square => LineCap::Square, + })); + self.op(CanvasOp::LineJoin(match stroke.linejoin() { + usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter, + usvg::LineJoin::Round => LineJoin::Round, + usvg::LineJoin::Bevel => LineJoin::Bevel, + })); + self.op(CanvasOp::MiterLimit(stroke.miterlimit().get())); + let dashes: Arc<[Points]> = stroke + .dasharray() + .unwrap_or_default() + .iter() + .map(|&d| px(d)) + .collect(); + self.op(CanvasOp::LineDash(dashes)); + self.op(CanvasOp::LineDashOffset(Num::Const(stroke.dashoffset()))); + } + + /// Fills or strokes `data` (`ops` as canvas path operations) with + /// `paint` at `alpha`. + fn paint( + &mut self, + paint: &usvg::Paint, + alpha: f32, + target: Target, + data: &tiny_skia_path::Path, + ops: &Path, + ) { + if alpha <= 0.0 { + return; + } + self.op(CanvasOp::GlobalAlpha(Num::Const(alpha))); + let style = match paint { + usvg::Paint::Color(c) if *c == self.current => { + let view = match target { + Target::Fill(_) => ViewPaint::FillCurrentColor, + Target::Stroke => ViewPaint::StrokeCurrentColor, + }; + self.view_paint(view, target.style(Paint::Color(Color::TRANSPARENT))); + self.op(target.draw(ops.clone())); + return; + } + usvg::Paint::Color(c) => Paint::Color(Color::rgba(c.red, c.green, c.blue, 255)), + usvg::Paint::LinearGradient(g) => { + let ts = g.transform(); + let (start, end) = linear_line(ts, (g.x1(), g.y1()), (g.x2(), g.y2())); + gradient( + GradientShape::Linear { start, end }, + g.stops(), + self.current, + ) + } + usvg::Paint::RadialGradient(g) => { + let ts = g.transform(); + let (focal, center, r) = ((g.fx(), g.fy()), (g.cx(), g.cy()), g.r().get()); + // A rotation and a uniform scale keep circles circles. + let scale = ts + .sx + .abs() + .max(ts.sy.abs()) + .max(ts.kx.abs()) + .max(ts.ky.abs()); + let similar = + (ts.sx - ts.sy).abs() <= 1e-4 * scale && (ts.kx + ts.ky).abs() <= 1e-4 * scale; + if !similar + && let Target::Fill(rule) = target + && let Some(back) = ts.invert() + { + // Stretched into an ellipse: the circles are drawn in + // the gradient's own space, the shape mapped into it. + let shape = radial((None, focal, center, r)); + let local = data.clone().transform(back).map(|p| path_ops(&p)); + self.op(CanvasOp::Save); + self.transform(ts); + self.op(CanvasOp::FillStyle(gradient( + shape, + g.stops(), + self.current, + ))); + if let Some(local) = local { + self.op(CanvasOp::Fill(Some(local), rule)); + } + self.op(CanvasOp::Restore); + return; + } + gradient( + radial((Some(ts), focal, center, r)), + g.stops(), + self.current, + ) + } + usvg::Paint::Pattern(_) => { + log::warn!("svg: a pattern is not drawn"); + return; + } + }; + self.op(target.style(style)); + self.op(target.draw(ops.clone())); + } +} + +/// What a paint is for. +#[derive(Clone, Copy)] +enum Target { + Fill(FillRule), + Stroke, +} + +impl Target { + fn style(self, paint: Paint) -> CanvasOp { + match self { + Target::Fill(_) => CanvasOp::FillStyle(paint), + Target::Stroke => CanvasOp::StrokeStyle(paint), + } + } + + fn draw(self, path: Path) -> CanvasOp { + match self { + Target::Fill(rule) => CanvasOp::Fill(Some(path), rule), + Target::Stroke => CanvasOp::Stroke(Some(path)), + } + } +} + +fn identity() -> tiny_skia_path::Transform { + tiny_skia_path::Transform::identity() +} + +fn at(x: f32, y: f32) -> Point { + point(px(x), px(y)) +} + +/// A radial gradient from the focal point to the circle around `center` +/// of radius `r`, mapped by a transform (its scale the square root of its +/// determinant's). +fn radial( + (ts, focal, center, r): ( + Option, + (f32, f32), + (f32, f32), + f32, + ), +) -> GradientShape { + let ts = ts.unwrap_or_else(identity); + let map = |(x, y): (f32, f32)| { + let mut p = tiny_skia_path::Point::from_xy(x, y); + ts.map_point(&mut p); + at(p.x, p.y) + }; + let scale = (ts.sx * ts.sy - ts.kx * ts.ky).abs().sqrt(); + GradientShape::Radial { + start: map(focal), + start_radius: Points::ZERO, + end: map(center), + end_radius: px(r * scale), + } +} + +/// The line of a linear gradient from `p1` to `p2` mapped by `ts`: an +/// affine map keeps the lines of equal color parallel, so the gradient is +/// the one along their normal, from where `p1` goes. +fn linear_line( + ts: tiny_skia_path::Transform, + (x1, y1): (f32, f32), + (x2, y2): (f32, f32), +) -> (Point, Point) { + let (dx, dy) = (x2 - x1, y2 - y1); + let det = ts.sx * ts.sy - ts.kx * ts.ky; + let length = dx * dx + dy * dy; + let mut start = tiny_skia_path::Point::from_xy(x1, y1); + ts.map_point(&mut start); + if det == 0.0 || length == 0.0 { + return (at(start.x, start.y), at(start.x, start.y)); + } + // The gradient's direction in the user space: the inverse transpose + // of the transform's linear part applied to the line, over its length + // squared, so that the color at `u` is `(u - start) · g`. + let gx = (ts.sy * dx - ts.ky * dy) / det / length; + let gy = (-ts.kx * dx + ts.sx * dy) / det / length; + let g2 = gx * gx + gy * gy; + ( + at(start.x, start.y), + at(start.x + gx / g2, start.y + gy / g2), + ) +} + +/// A gradient of `stops`; one of `current`, the root's `color`, is black, +/// the color of a `currentColor` that inherits none (a gradient's stops +/// are fixed when it is recorded). +fn gradient(shape: GradientShape, stops: &[usvg::Stop], current: usvg::Color) -> Paint { + let stops = stops + .iter() + .map(|s| { + let c = Some(s.color()) + .filter(|&c| c != current) + .unwrap_or_else(usvg::Color::black); + let a = (s.opacity().get() * 255.0).round() as u8; + ( + Percentage(s.offset().get()), + Color::rgba(c.red, c.green, c.blue, a), + ) + }) + .collect(); + Paint::Gradient(Arc::new(CanvasGradient { shape, stops })) +} + +fn fill_rule(rule: usvg::FillRule) -> FillRule { + match rule { + usvg::FillRule::NonZero => FillRule::NonZero, + usvg::FillRule::EvenOdd => FillRule::EvenOdd, + } +} + +fn composite(mode: usvg::BlendMode) -> Option { + use usvg::BlendMode as B; + Some(match mode { + B::Normal => return None, + B::Multiply => Composite::Multiply, + B::Screen => Composite::Screen, + B::Overlay => Composite::Overlay, + B::Darken => Composite::Darken, + B::Lighten => Composite::Lighten, + B::ColorDodge => Composite::ColorDodge, + B::ColorBurn => Composite::ColorBurn, + B::HardLight => Composite::HardLight, + B::SoftLight => Composite::SoftLight, + B::Difference => Composite::Difference, + B::Exclusion => Composite::Exclusion, + B::Hue => Composite::Hue, + B::Saturation => Composite::Saturation, + B::Color => Composite::Color, + B::Luminosity => Composite::Luminosity, + }) +} + +/// The shapes of a clip path's `group`, mapped by `ts`, appended to `ops` +/// with their clip rules. +fn collect_clip( + group: &usvg::Group, + ts: tiny_skia_path::Transform, + ops: &mut Vec, + rules: &mut Vec, +) { + let ts = ts.pre_concat(group.transform()); + for node in group.children() { + match node { + usvg::Node::Group(g) => collect_clip(g, ts, ops, rules), + usvg::Node::Path(p) if p.is_visible() => { + if let Some(data) = p.data().clone().transform(ts) { + append_path(&data, ops); + rules.push(p.fill().map_or(FillRule::NonZero, |f| fill_rule(f.rule()))); + } + } + _ => {} + } + } +} + +fn path_ops(data: &tiny_skia_path::Path) -> Path { + let mut ops = Vec::with_capacity(data.len()); + append_path(data, &mut ops); + ops.into() +} + +fn append_path(data: &tiny_skia_path::Path, ops: &mut Vec) { + use tiny_skia_path::PathSegment as S; + let n = |v: f32| Num::Const(v); + for segment in data.segments() { + ops.push(match segment { + S::MoveTo(p) => PathOp::MoveTo(n(p.x), n(p.y)), + S::LineTo(p) => PathOp::LineTo(n(p.x), n(p.y)), + S::QuadTo(c, p) => PathOp::QuadTo([c.x, c.y, p.x, p.y].map(n)), + S::CubicTo(c1, c2, p) => PathOp::CubicTo([c1.x, c1.y, c2.x, c2.y, p.x, p.y].map(n)), + S::Close => PathOp::Close, + }); + } +} diff --git a/third-party/nsh/crates/nsh-core/src/svg/drawings.rs b/third-party/nsh/crates/nsh-core/src/svg/drawings.rs new file mode 100644 index 0000000000..8974efb981 --- /dev/null +++ b/third-party/nsh/crates/nsh-core/src/svg/drawings.rs @@ -0,0 +1,89 @@ +//! The drawings [`crate::element::svg`]s show, parsed once per source and +//! kept in the decoded images' budget (`docs/dev/images.md`): an entry is +//! leased while an element shows it and shared by every size, as it is +//! vector. + +use std::sync::{Arc, LazyLock}; + +use crate::{ + canvas::Drawing, + image::{ + ImageData, Lease, MemoryCache, + loader::{Context, Location, Priority, loader}, + }, +}; + +/// The drawings by source key. +fn drawings() -> &'static MemoryCache> { + static DRAWINGS: LazyLock>> = + LazyLock::new(|| MemoryCache::new(crate::image::budget())); + &DRAWINGS +} + +/// The drawing of `location` here and now: in memory, or parsed from the +/// bytes it carries (inline markup). +pub(crate) fn now(location: &Location) -> Option<(Arc, Lease)> { + let key = location.key(); + if let Some(hit) = drawings().get(&key) { + return Some(hit); + } + match location { + Location::Memory(_, bytes) => Some(parsed(key, bytes)), + _ => None, + } +} + +/// Loads the drawing of `location` through `context` (its bytes through the +/// shared loader, parsed on a decode thread); dropping the future gives up +/// the load. One that fails draws nothing. +pub(crate) fn load( + location: Location, + context: Context, +) -> impl Future, Lease)> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let key = location.key(); + let ticket = loader().read( + location, + &context, + Priority::Visible, + None, + Box::new(move |bytes| { + loader() + .decode(Priority::Visible, move || { + let shown = match bytes { + // Another element may have parsed it meanwhile. + Ok(bytes) => drawings().get(&key).unwrap_or_else(|| parsed(key, &bytes)), + Err(e) => { + log::warn!("svg: {e}"); + (Arc::default(), Lease::none()) + } + }; + let _ = tx.send(shown); + }) + .detach(); + }), + ); + async move { + let _ticket = ticket; + rx.await.unwrap_or_else(|_| (Arc::default(), Lease::none())) + } +} + +/// Parses `bytes` and keeps the drawing as `key`'s. +fn parsed(key: String, bytes: &ImageData) -> (Arc, Lease) { + let drawing = std::str::from_utf8(bytes) + .map_err(|_| super::Error::Invalid("not UTF-8 text".into())) + .and_then(super::drawing); + let drawing = Arc::new(drawing.unwrap_or_else(|e| { + log::warn!("svg: {e}"); + Drawing::default() + })); + let lease = drawings().insert(key, drawing.clone(), cost(&drawing)); + (drawing, lease) +} + +/// What a drawing costs, estimated from its operations (each with its share +/// of path data). +fn cost(drawing: &Drawing) -> usize { + 256 + drawing.ops.len() * 2 * std::mem::size_of::() +} diff --git a/third-party/nsh/crates/nsh-core/src/svg/mod.rs b/third-party/nsh/crates/nsh-core/src/svg/mod.rs index 059eaa24a6..097879e7b7 100644 --- a/third-party/nsh/crates/nsh-core/src/svg/mod.rs +++ b/third-party/nsh/crates/nsh-core/src/svg/mod.rs @@ -1,4 +1,5 @@ -//! SVG images for the native backends (feature `svg`). +//! SVG images for the native backends (feature `svg`), and SVGs as +//! canvas drawings ([`drawing`], what `element::svg` shows). //! //! The platforms' image decoders (ImageIO, `BitmapFactory`, WIC, GDK) do not //! read SVG, so every native backend hands sniffed SVG bytes to this module @@ -38,10 +39,15 @@ use resvg::{ use crate::geometry::{DevicePixels, Points, Size}; +mod drawing; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod drawings; mod layers; #[cfg(test)] mod tests; +pub use self::drawing::drawing; + /// How much of a compressed SVG (`.svgz`) [`sniff`] inflates to find the /// root element. const SNIFF_INFLATED: u64 = 4096; @@ -308,15 +314,11 @@ impl<'a> OneSide<'a> { if value.trim_end().ends_with('%') { return None; } - let start = root.range().start + 1; - let at = text[start..] - .find(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/') - .map_or(text.len(), |i| start + i); Some(OneSide { width, value, view_box: (vw, vh), - at, + at: root_attributes_at(doc, text), }) } @@ -370,6 +372,15 @@ impl<'a> OneSide<'a> { } } +/// Where an attribute added to the root element goes in `text`, the +/// document `doc` was parsed from: after the root's tag name. +fn root_attributes_at(doc: &roxmltree::Document<'_>, text: &str) -> usize { + let start = doc.root_element().range().start + 1; + text[start..] + .find(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/') + .map_or(text.len(), |i| start + i) +} + /// Inflates a `.svgz` up to [`MAX_BYTES`]. fn inflate(bytes: &[u8]) -> Result, Error> { let mut inflated = Vec::new(); diff --git a/third-party/nsh/crates/nsh-core/src/svg/tests.rs b/third-party/nsh/crates/nsh-core/src/svg/tests.rs index ea54099216..c6635b38a4 100644 --- a/third-party/nsh/crates/nsh-core/src/svg/tests.rs +++ b/third-party/nsh/crates/nsh-core/src/svg/tests.rs @@ -536,3 +536,189 @@ fn svg_safety_text_is_not_drawn() { assert!(transparent(&raster)); assert_eq!(raster.size, pt(40.0, 20.0)); } + +/// What a drawing of an SVG ([`drawing`]) fills and strokes, painted for a +/// box of `size` with text color `current`: each paint with the operations +/// before it (the state it draws with). +mod as_drawing { + use super::*; + use crate::{ + canvas::{ + CanvasOp, Drawing, FillRule, GradientShape, Num, Paint, PathOp, ViewPaint, + point_in_path, + }, + geometry::{Point, point, px}, + style::{Color, PaintStyle}, + }; + + const GREEN: Color = Color(0x00ff_00ff); + + fn paint(drawing: &Drawing, size: Size) -> Drawing { + drawing.painted(&PaintStyle::default(), GREEN, Color::TRANSPARENT, &[], size) + } + + /// The paint of each shape, in order: `(fill or stroke style, the + /// operations up to the shape, its path)`. + fn shapes(drawing: &Drawing) -> Vec<(Paint, &[CanvasOp], &[PathOp])> { + let (mut fill, mut stroke) = (None, None); + let mut out = Vec::new(); + for (i, op) in drawing.ops.iter().enumerate() { + match op { + CanvasOp::FillStyle(p) => fill = Some(p.clone()), + CanvasOp::StrokeStyle(p) => stroke = Some(p.clone()), + CanvasOp::Fill(Some(path), _) => { + out.push((fill.clone().unwrap(), &drawing.ops[..i], &path[..])) + } + CanvasOp::Stroke(Some(path)) => { + out.push((stroke.clone().unwrap(), &drawing.ops[..i], &path[..])) + } + _ => {} + } + } + out + } + + fn inside(ops: &[CanvasOp], path: &[PathOp], x: f32, y: f32) -> bool { + point_in_path(ops, Some(path), FillRule::NonZero, at(x, y), |_| 0.0) + } + + fn at(x: f32, y: f32) -> Point { + point(px(x), px(y)) + } + + /// An icon stroked with `currentColor` takes the view's text color, + /// whatever it is when drawn; its own colors stay. + #[test] + fn svg_drawing_current_color_is_the_text_color() { + let icon = r##""##; + let d = drawing(icon).unwrap(); + assert!( + d.view_paints + .iter() + .any(|(_, v)| *v == ViewPaint::StrokeCurrentColor) + ); + let painted = paint(&d, pt(24.0, 24.0)); + let paints: Vec<_> = shapes(&painted).into_iter().map(|s| s.0).collect(); + assert_eq!( + paints, + [Paint::Color(GREEN), Paint::Color(Color(0x0000_ffff))] + ); + assert!(painted.ops.contains(&CanvasOp::LineWidth(Num::Const(2.0)))); + // A `color` of the document's own is what its `currentColor` is. + let own = drawing( + r##""##, + ) + .unwrap(); + assert_eq!( + shapes(&painted_of(&own))[0].0, + Paint::Color(Color(0xff00_00ff)) + ); + } + + fn painted_of(d: &Drawing) -> Drawing { + paint(d, d.fit().unwrap().size) + } + + /// Gradients are drawn as gradients, in the shape's coordinates + /// (`objectBoundingBox` included), with their stops' opacity. + #[test] + fn svg_drawing_keeps_gradients() { + let svg = r##" + + + + + + + "##; + let painted = painted_of(&drawing(svg).unwrap()); + let shapes = shapes(&painted); + let Paint::Gradient(linear) = &shapes[0].0 else { + panic!("{:?}", shapes[0].0) + }; + let GradientShape::Linear { start, end } = linear.shape else { + panic!("{linear:?}") + }; + // From the box's left edge to its right one. + assert!((start.x.0 - 10.0).abs() < 1e-3 && (end.x.0 - 90.0).abs() < 1e-3); + assert!((start.y.0 - end.y.0).abs() < 1e-3); + assert_eq!( + linear + .stops + .iter() + .map(|(o, c)| (o.0, *c)) + .collect::>(), + [(0.0, Color(0xff00_00ff)), (1.0, Color(0x0000_ff80))] + ); + let Paint::Gradient(radial) = &shapes[1].0 else { + panic!("{:?}", shapes[1].0) + }; + assert_eq!( + radial.shape, + GradientShape::Radial { + start: at(50.0, 50.0), + start_radius: Points::ZERO, + end: at(50.0, 50.0), + end_radius: px(40.0), + } + ); + } + + /// The view box is fitted into the canvas's box as + /// `preserveAspectRatio` says: in the middle by default, stretched by + /// `none`, covering it by `slice`; the canvas's natural size is the + /// SVG's `width` and `height`. + #[test] + fn svg_drawing_fits_its_view_box_into_the_box() { + let tall = |aspect: &str| { + drawing(&format!( + r##""## + )) + .unwrap() + }; + let meet = tall(""); + assert_eq!(meet.fit().unwrap().size, pt(5.0, 10.0)); + let painted = paint(&meet, pt(40.0, 40.0)); + let (_, ops, path) = shapes(&painted)[0]; + // 20 × 40 in the middle of 40 × 40. + assert!(inside(ops, path, 11.0, 1.0) && inside(ops, path, 29.0, 39.0)); + assert!(!inside(ops, path, 9.0, 20.0) && !inside(ops, path, 31.0, 20.0)); + let painted = painted_of(&tall(r#"preserveAspectRatio="none""#)); + let (_, ops, path) = shapes(&painted)[0]; + assert!(inside(ops, path, 4.9, 9.9) && !inside(ops, path, 5.1, 5.0)); + let stretched = drawing( + r##""##, + ) + .unwrap(); + let painted = paint(&stretched, pt(40.0, 40.0)); + let (_, ops, path) = shapes(&painted)[0]; + assert!(inside(ops, path, 1.0, 1.0) && inside(ops, path, 39.0, 39.0)); + // Sliced: 40 × 80 from 20 above the top; its top left quarter is + // a red square, the rest blue. + let sliced = drawing( + r##""##, + ) + .unwrap(); + let painted = paint(&sliced, pt(40.0, 40.0)); + let (_, ops, red) = shapes(&painted)[1]; + assert!(inside(ops, red, 19.0, 19.0) && !inside(ops, red, 21.0, 19.0)); + assert!(!inside(ops, red, 19.0, 21.0)); + // A `width` and `height` beside the view box scale what usvg + // draws; the drawing stays in the view box's coordinates. + let big = drawing( + r##""##, + ) + .unwrap(); + assert_eq!(big.fit().unwrap().size, pt(48.0, 48.0)); + let painted = paint(&big, pt(24.0, 24.0)); + let (_, ops, path) = shapes(&painted)[0]; + assert!(inside(ops, path, 11.0, 11.0) && !inside(ops, path, 13.0, 5.0)); + } + + /// A document that is no SVG fails as the rasters do. + #[test] + fn svg_drawing_of_no_svg_fails() { + assert!(matches!(drawing(""), Err(Error::Invalid(_)))); + assert!(drawing("not xml").is_err()); + } +} diff --git a/third-party/nsh/crates/nsh-core/src/tailwind/mod.rs b/third-party/nsh/crates/nsh-core/src/tailwind/mod.rs index b154972687..4362190548 100644 --- a/third-party/nsh/crates/nsh-core/src/tailwind/mod.rs +++ b/third-party/nsh/crates/nsh-core/src/tailwind/mod.rs @@ -71,6 +71,18 @@ pub mod state { /// `accessibilityState.expanded`, which `Popover` and `Menu` set on /// their trigger. Set by the core, never reported by the platform. pub const EXPANDED: u16 = 256; + /// The view is a toggle button that is on (`aria-pressed:`): its + /// `accessibilityState.pressed` is `true` (not mixed). Not + /// [`PRESSED`], a press in progress (`active:`). Set by the core, + /// never reported by the platform. + pub const ARIA_PRESSED: u16 = 512; + /// The view is busy (`aria-busy:`): its `accessibilityState.busy`. + /// Set by the core, never reported by the platform. + pub const BUSY: u16 = 1024; + /// The states the core sets from the accessibility state, which the + /// platforms never report. + pub(crate) const ACCESSIBILITY: u16 = + SELECTED | CHECKED | INDETERMINATE | EXPANDED | ARIA_PRESSED | BUSY; } /// The view's place among its siblings (`first:`, `odd:`, ...). @@ -384,10 +396,15 @@ pub(crate) mod rank { pub(crate) const ACTIVE: Key = Key::new(37); pub(crate) const ENABLED: Key = Key::new(38); pub(crate) const DISABLED: Key = Key::new(39); - /// Tailwind's `aria-*` variants come after the pseudo-classes. - pub(crate) const EXPANDED: Key = Key::new(40); - pub(crate) const MOTION_SAFE: Key = Key::new(41); - pub(crate) const MOTION_REDUCE: Key = Key::new(42); + /// Tailwind's `aria-*` variants come after the pseudo-classes, in the + /// order of their names. + pub(crate) const ARIA_BUSY: Key = Key::new(40); + pub(crate) const ARIA_CHECKED: Key = Key::new(41); + pub(crate) const EXPANDED: Key = Key::new(42); + pub(crate) const ARIA_PRESSED: Key = Key::new(43); + pub(crate) const ARIA_SELECTED: Key = Key::new(44); + pub(crate) const MOTION_SAFE: Key = Key::new(45); + pub(crate) const MOTION_REDUCE: Key = Key::new(46); /// `max-*` breakpoints, widest first. pub(crate) const MAX: u16 = 50; /// Breakpoints and `min-*`, narrowest first. @@ -781,12 +798,7 @@ impl ClassList { fn add(&mut self, rule: Rule) { self.deps |= rule.cond.deps(); - self.tracked |= rule.cond.states - & !(state::DISABLED - | state::SELECTED - | state::CHECKED - | state::INDETERMINATE - | state::EXPANDED); + self.tracked |= rule.cond.states & !(state::DISABLED | state::ACCESSIBILITY); if rule.cond.states & state::FOCUS_WITHIN != 0 { self.tracked |= state::FOCUS_WITHIN; } @@ -1128,10 +1140,26 @@ fn variant(theme: &Theme, v: &str, cond: &mut Condition) -> Option { states(cond, DISABLED); rank::DISABLED } + "aria-busy" => { + states(cond, BUSY); + rank::ARIA_BUSY + } + "aria-checked" => { + states(cond, CHECKED); + rank::ARIA_CHECKED + } "aria-expanded" => { states(cond, EXPANDED); rank::EXPANDED } + "aria-pressed" => { + states(cond, ARIA_PRESSED); + rank::ARIA_PRESSED + } + "aria-selected" => { + states(cond, SELECTED); + rank::ARIA_SELECTED + } "motion-safe" => { cond.reduce_motion = Some(false); rank::MOTION_SAFE @@ -1189,7 +1217,11 @@ fn variant(theme: &Theme, v: &str, cond: &mut Condition) -> Option { "selected" => (SELECTED, rank::SELECTED), "checked" => (CHECKED, rank::CHECKED), "indeterminate" => (INDETERMINATE, rank::INDETERMINATE), + "aria-busy" => (BUSY, rank::ARIA_BUSY), + "aria-checked" => (CHECKED, rank::ARIA_CHECKED), "aria-expanded" => (EXPANDED, rank::EXPANDED), + "aria-pressed" => (ARIA_PRESSED, rank::ARIA_PRESSED), + "aria-selected" => (SELECTED, rank::ARIA_SELECTED), _ => return None, }; let entry = cond.group.get_or_insert_with(|| (name.into(), 0)); diff --git a/third-party/nsh/crates/nsh-core/src/tailwind/tests.rs b/third-party/nsh/crates/nsh-core/src/tailwind/tests.rs index f386376c2e..2f8dc64a9e 100644 --- a/third-party/nsh/crates/nsh-core/src/tailwind/tests.rs +++ b/third-party/nsh/crates/nsh-core/src/tailwind/tests.rs @@ -1200,6 +1200,112 @@ fn expanded_variants() { assert_eq!(s.view.background_color, Color(0x0000_00ff)); } +/// `aria-pressed:`, `aria-busy:`, `aria-checked:` and `aria-selected:` +/// as Tailwind v4 has them (`&[aria-pressed="true"]`, ...): the core's +/// states from the accessibility state, ordered by their names after the +/// pseudo-classes. +#[test] +fn aria_state_variants() { + let theme = themed(); + let list = parse( + &theme, + "aria-pressed:bg-black aria-busy:opacity-50 aria-checked:p-1 aria-selected:p-2 \ + group-aria-pressed:rotate-180 group-aria-busy:opacity-0 group-aria-checked:p-3 \ + group-aria-selected:p-4", + ); + assert!(list.unknown().is_empty(), "{:?}", list.unknown()); + assert!(list.deps() & deps::STATE != 0 && list.deps() & deps::GROUP != 0); + // The core sets them: nothing for the platform to report. + assert_eq!(list.tracked_states(), 0); + // `aria-current:` and the states NSH does not have are unknown, as + // Tailwind has no `aria-current` variant (`aria-[current=page]:` is + // an arbitrary one). + let unknown = parse(&theme, "aria-current:bg-black aria-readonly:bg-black"); + assert_eq!(unknown.unknown().len(), 2, "{:?}", unknown.unknown()); + let env = ClassEnvironment::default(); + let at = (390.0, 844.0); + let (s, _) = styled_with( + "bg-white aria-pressed:bg-black", + env, + at, + state::ARIA_PRESSED, + ); + assert_eq!(s.view.background_color, Color(0x0000_00ff)); + // Not a press in progress: `active:` waits for its own state. + let (s, _) = styled_with("bg-white aria-pressed:bg-black", env, at, state::PRESSED); + assert_eq!(s.view.background_color, Color(0xffff_ffff)); + let (s, _) = styled_with( + "active:bg-white aria-pressed:bg-black", + env, + at, + state::PRESSED, + ); + assert_eq!(s.view.background_color, Color(0xffff_ffff)); + // The `aria-*` variants come after the pseudo-classes: a toggle that + // is on keeps its look while pressed or hovered. + let pressed = state::ARIA_PRESSED | state::HOVERED | state::PRESSED; + let (s, _) = styled_with( + "hover:bg-white active:bg-white aria-pressed:bg-black", + env, + at, + pressed, + ); + assert_eq!(s.view.background_color, Color(0x0000_00ff)); + // Among themselves, by their names: busy, checked, expanded, pressed, + // selected. + let all = + state::BUSY | state::CHECKED | state::EXPANDED | state::ARIA_PRESSED | state::SELECTED; + let order = [ + ("aria-busy", Color(0xfb2c_36ff)), + ("aria-checked", Color(0x0000_00ff)), + ("aria-expanded", Color(0xffff_ffff)), + ("aria-pressed", Color(0x00bc_7dff)), + ("aria-selected", Color(0x2b7f_ffff)), + ]; + let classes = |order: &[(&str, Color)]| { + let names = ["red-500", "black", "white", "emerald-500", "blue-500"]; + order + .iter() + .zip(names) + .map(|((v, _), c)| format!("{v}:bg-{c}")) + .collect::>() + }; + let mut reversed = classes(&order); + reversed.reverse(); + let (s, _) = styled_with(&reversed.join(" "), env, at, all); + assert_eq!(s.view.background_color, order[4].1); + for i in 0..4 { + let below = order[..=i].iter().fold(0, |states, (v, _)| { + states + | match *v { + "aria-busy" => state::BUSY, + "aria-checked" => state::CHECKED, + "aria-expanded" => state::EXPANDED, + "aria-pressed" => state::ARIA_PRESSED, + _ => state::SELECTED, + } + }); + let (s, _) = styled_with(&reversed.join(" "), env, at, below); + assert_eq!(s.view.background_color, order[i].1, "{}", order[i].0); + } + // `aria-checked:` is checked, after `checked:`; `aria-selected:` is + // selected, after `selected:`. + let (s, _) = styled_with( + "aria-checked:bg-black checked:bg-white", + env, + at, + state::CHECKED, + ); + assert_eq!(s.view.background_color, Color(0x0000_00ff)); + let (s, _) = styled_with( + "aria-selected:bg-black selected:bg-white", + env, + at, + state::SELECTED, + ); + assert_eq!(s.view.background_color, Color(0x0000_00ff)); +} + #[test] fn indeterminate_variants() { let theme = themed(); diff --git a/third-party/nsh/crates/nsh-core/src/text.rs b/third-party/nsh/crates/nsh-core/src/text.rs index 14bde1f263..0455c2562e 100644 --- a/third-party/nsh/crates/nsh-core/src/text.rs +++ b/third-party/nsh/crates/nsh-core/src/text.rs @@ -495,6 +495,21 @@ impl AttributedText { self.spans.iter().any(|s| s.style.background.is_some()) } + /// The color every character is drawn in, decoration lines included, + /// if there is one (`None` for several, or no text): what a transition + /// of the text's color animates ([`crate::transition::recolor`]). + pub fn uniform_color(&self) -> Option { + let color = self.spans.first()?.style.color; + self.spans + .iter() + .all(|s| { + s.style.color == color + && (s.style.decoration.is_none() + || s.style.decoration_color.is_none_or(|c| c == color)) + }) + .then_some(color) + } + /// The span containing the UTF-16 offset `index` (the last one past the /// end). pub fn span_at(&self, index: u32) -> Option<&TextSpan> { diff --git a/third-party/nsh/crates/nsh-core/src/time.rs b/third-party/nsh/crates/nsh-core/src/time.rs index c8d0f465a1..49f20dd410 100644 --- a/third-party/nsh/crates/nsh-core/src/time.rs +++ b/third-party/nsh/crates/nsh-core/src/time.rs @@ -47,6 +47,13 @@ pub fn manual() -> bool { CURRENT.with(|current| current.borrow().as_ref().is_some_and(Clock::is_manual)) } +/// The current thread's clock ([`Clock::enter`]), else the system's: for +/// what keeps reading an engine's time outside its calls (a browser realm's +/// `Date` between pumps). +pub fn current() -> Clock { + CURRENT.with(|current| current.borrow().clone().unwrap_or_default()) +} + /// A display frame's time at 60 Hz: what a manual clock that moves with /// the display's frames moves by each frame ([`Clock::with_frames`]). pub const FRAME: Duration = Duration::from_nanos(16_666_667); diff --git a/third-party/nsh/crates/nsh-core/src/transition.rs b/third-party/nsh/crates/nsh-core/src/transition.rs index 0a45eb2709..ef3bdddd66 100644 --- a/third-party/nsh/crates/nsh-core/src/transition.rs +++ b/third-party/nsh/crates/nsh-core/src/transition.rs @@ -1,18 +1,26 @@ //! What animates on one view, without the view: `LayoutAnimation` -//! (frames, insertions, removals), style `transition`s and keyframe -//! `animation`s (`spin`, `ping`, `pulse`, `bounce`), stepped by a desktop -//! backend's frame clock (Windows, GPUI, Linux and macOS share it). +//! (frames, insertions, removals), style `transition`s (a text's color +//! included) and keyframe `animation`s (`spin`, `ping`, `pulse`, +//! `bounce`), stepped by a desktop backend's frame clock (Windows, GPUI, +//! Linux and macOS share it). //! //! A backend keeps a [`Live`] beside a view that animates and runs a //! frame callback while it has something to do; other views carry //! nothing and cost nothing. The backend tells [`Live`] what changed //! ([`Live::appear`], [`Live::disappear`], [`Live::move_frame`], -//! [`restyle`]) and calls [`Live::step`] on every frame with the view's +//! [`restyle`], [`recolor`]) and calls [`Live::step`] on every frame with the view's //! frame and style, which it writes the in-between values into: everything //! that draws reads the current value without knowing about transitions, //! and a change that arrives mid-animation starts from where the view is, //! as UIKit's `beginFromCurrentState`. //! +//! A text's color is not in its style but in its typeset runs, which a +//! new color replaces (laid out again, not measured again): while its +//! transition runs, the backend draws the text in [`Live::text_color`] +//! over the runs' colors, as it does an animated color, so no frame +//! typesets it again. iOS, whose other transitions are Core Animation's, +//! steps the same [`TextColor`] on a display link of its own. +//! //! Appearing / disappearing views and keyframes do not touch the style: //! they multiply the opacity ([`Live::opacity`]) and add a transform to the //! style's ([`Live::extra`]), which the backend applies in its own matrix @@ -23,8 +31,8 @@ use std::sync::Arc; use crate::{ - Bounds, BoxShadow, Color, Corners, Edges, KeyframeAnimation, Keyframes, LayoutCurve, - LayoutProperty, LayoutTransition, Points, Transform, TransitionProps, ViewStyle, + AttributedText, Bounds, BoxShadow, Color, Corners, Edges, KeyframeAnimation, Keyframes, + LayoutCurve, LayoutProperty, LayoutTransition, Points, Transform, TransitionProps, ViewStyle, geometry::{Percentage, Point, Radians, Size, deg}, }; @@ -37,6 +45,8 @@ pub struct Live { frame: Option, /// Style values moving to the new style's. style: Option, + /// A text's color moving to its new one. + color: TextColor, /// Appearing or disappearing. presence: Option, /// A keyframe animation. @@ -55,6 +65,7 @@ impl Default for Live { ticking: false, frame: None, style: None, + color: TextColor::default(), presence: None, keyframes: None, dying: false, @@ -75,7 +86,7 @@ pub struct Step { pub finished: bool, /// The frame's size changed. pub resized: bool, - /// The style transition moved (or ended). + /// The style or text color transition moved (or ended). pub restyled: bool, } @@ -96,10 +107,18 @@ impl Live { self.dying } + /// The color a running text color transition draws the text in, over + /// its runs' own: a text view draws all of it (decoration lines + /// included) in this color while it is `Some`. + pub fn text_color(&self) -> Option { + self.color.value() + } + /// True when nothing animates. pub fn idle(&self) -> bool { self.frame.is_none() && self.style.is_none() + && !self.color.running() && self.presence.is_none() && self.keyframes.is_none() } @@ -192,6 +211,12 @@ impl Live { } } + /// Starts, keeps or stops a text color transition, as [`recolor`] + /// found ([`TextColor::recolor`]). + pub fn recolor(&mut self, recolor: Recolor) { + self.color.recolor(recolor); + } + /// True if the view is removed and has not begun to animate out: a /// view deleted inside a deleted subtree leaves with its ancestor /// ([`Live::leave_with_ancestor`]) instead of fading on its own. @@ -248,6 +273,10 @@ impl Live { } } + if self.color.step(now) { + step.restyled = true; + } + let (mut opacity, mut extra) = (1.0, Extra::NONE); if let Some(presence) = &mut self.presence { let at = presence.timing.at(now); @@ -336,6 +365,136 @@ pub fn restyle( Some(Restyle { track, keyframes }) } +/// What a text's new content does to a transition of its color, from +/// [`recolor`] for [`Live::recolor`] or [`TextColor::recolor`]. +#[non_exhaustive] +pub struct Recolor(Recoloring); + +enum Recoloring { + /// The color transitions. + Start { + from: Color, + to: Color, + timing: Timing, + }, + /// The color is the same: a transition running to it goes on. + Keep(Color), + /// The new colors apply at once. + Stop, +} + +impl Default for Recolor { + /// The new colors apply at once. + fn default() -> Self { + Recolor(Recoloring::Stop) + } +} + +impl Recolor { + /// True if it starts a transition (the view needs a [`Live`] and a + /// frame callback); otherwise it only matters to a [`Live`] the view + /// already has. + pub fn starts(&self) -> bool { + matches!(self.0, Recoloring::Start { .. }) + } +} + +/// A text's color moving to a new one, as [`recolor`] found: what +/// [`Live`] steps for the desktop backends, and what a backend that keeps +/// no [`Live`] (iOS) steps on its own frame clock. While it runs, the +/// text view draws all of its text in [`TextColor::value`] over its runs' +/// own colors, so no frame lays the text out again. +#[non_exhaustive] +#[derive(Default)] +pub struct TextColor(Option); + +impl TextColor { + /// The color to draw the whole text in (decoration lines included) + /// while the transition runs; `None` draws the runs' own colors. + pub fn value(&self) -> Option { + self.0.as_ref().map(|c| c.value) + } + + /// True while a transition runs: something steps it on every frame. + pub fn running(&self) -> bool { + self.0.is_some() + } + + /// Starts, keeps or stops the transition. A new one starts from the + /// color on screen, mid-transition included, as CSS reverses a + /// transition from where it is. + pub fn recolor(&mut self, recolor: Recolor) { + match recolor.0 { + Recoloring::Start { from, to, timing } => { + let from = self.value().unwrap_or(from); + self.0 = Some(ColorTrack { + from, + to, + value: from, + timing, + }); + } + Recoloring::Keep(to) => { + if self.0.as_ref().is_some_and(|c| c.to != to) { + self.0 = None; + } + } + Recoloring::Stop => self.0 = None, + } + } + + /// One frame at `now` (ms, any clock that only goes forward; the + /// first frame starts the transition): true if [`TextColor::value`] + /// changed, the text is drawn again. A transition that ends stops + /// running, and the runs' own colors (the new ones) draw again. + pub fn step(&mut self, now: f64) -> bool { + let Some(track) = &mut self.0 else { + return false; + }; + let Some((p, done)) = track.timing.at(now) else { + return false; + }; + if done { + self.0 = None; + } else { + track.value = lerp_color(track.from, track.to, p); + } + true + } +} + +/// A text view's content changed from `old` to `text`, its style being +/// `style`: whether its color transitions. It does when the style's +/// `transition` covers colors (`transition-colors`, `color`) and the text +/// is drawn in one color before and after +/// ([`AttributedText::uniform_color`]), as an element's `color` +/// transitions in CSS; a text of several colors takes its new ones at +/// once. The content may change with it: the color is drawn over the new +/// runs, which are not measured or typeset again. +pub fn recolor( + old: &AttributedText, + text: &AttributedText, + style: &ViewStyle, + reduce_motion: bool, +) -> Recolor { + let transition = style.transition.filter(|t| { + !reduce_motion && t.duration + t.delay > 0.0 && t.properties.has(TransitionProps::COLORS) + }); + // Most texts have no transition: their runs are not looked at. + let Some((t, to)) = transition.and_then(|t| Some((t, text.uniform_color()?))) else { + return Recolor::default(); + }; + Recolor(match old.uniform_color() { + Some(from) if from == to => Recoloring::Keep(to), + Some(from) => Recoloring::Start { + from, + to, + timing: Timing::new(t.delay, t.duration, Curve::Bezier(t.easing)), + }, + None => Recoloring::Stop, + }) +} + /// A rotation, scale and vertical shift around the transform origin, which /// appearing, disappearing and keyframes add to the style's transform. #[non_exhaustive] @@ -527,6 +686,14 @@ struct StyleTrack { timing: Timing, } +struct ColorTrack { + from: Color, + to: Color, + /// The color on screen. + value: Color, + timing: Timing, +} + struct Presence { property: LayoutProperty, /// Visibility at the start and end (0 hidden, 1 shown). @@ -1076,4 +1243,127 @@ mod tests { let step = live.step(2000.0, &mut frame, &mut style); assert!(step.removed && step.finished && !live.ticking()); } + + fn text(colors: &[Color]) -> AttributedText { + let mut text = AttributedText::default(); + for &color in colors { + let style = crate::ResolvedTextStyle { + color, + ..Default::default() + }; + text.push("Tab", style); + } + text + } + + fn transitioning(properties: u8) -> ViewStyle { + ViewStyle { + transition: Some(crate::Transition { + properties: TransitionProps(properties), + duration: 150.0, + delay: 0.0, + easing: [0.0, 0.0, 1.0, 1.0], + }), + ..ViewStyle::default() + } + } + + #[test] + fn a_text_color_transitions() { + let (red, blue) = (Color::rgba(255, 0, 0, 255), Color::rgba(0, 0, 255, 255)); + // `transition-colors duration-150`, linear. + let style = transitioning(TransitionProps::COLORS); + let recolor = recolor(&text(&[red]), &text(&[blue]), &style, false); + assert!(recolor.starts()); + let mut live = Live::default(); + live.recolor(recolor); + // The old color shows until the first frame. + assert_eq!(live.text_color(), Some(red)); + let (mut frame, mut shown) = (Bounds::default(), Box::default()); + live.step(1000.0, &mut frame, &mut shown); + assert_eq!(live.text_color(), Some(red)); + let step = live.step(1060.0, &mut frame, &mut shown); + assert!(step.restyled && !step.finished); + assert_eq!(live.text_color(), Some(Color::rgba(153, 0, 102, 255))); + // Back to red mid-way: from the color on screen. + live.recolor(super::recolor( + &text(&[blue]), + &text(&[red]), + &transitioning(TransitionProps::ALL), + false, + )); + assert_eq!(live.text_color(), Some(Color::rgba(153, 0, 102, 255))); + live.step(2000.0, &mut frame, &mut shown); + live.step(2060.0, &mut frame, &mut shown); + assert_eq!(live.text_color(), Some(Color::rgba(194, 0, 61, 255))); + // New content in the color it is heading to: it goes on. + let mut longer = text(&[red]); + longer.push(" 2", longer.spans[0].style); + live.recolor(super::recolor( + &text(&[red]), + &longer, + &transitioning(TransitionProps::COLORS), + false, + )); + assert!(live.text_color().is_some()); + // Done: the runs' own color draws again. + let step = live.step(2150.0, &mut frame, &mut shown); + assert!(step.restyled && step.finished && live.idle()); + assert_eq!(live.text_color(), None); + } + + #[test] + fn a_text_color_jumps_without_a_colors_transition() { + let (red, blue) = (Color::rgba(255, 0, 0, 255), Color::rgba(0, 0, 255, 255)); + let colors = transitioning(TransitionProps::COLORS); + let starts = |old: &[Color], new: &[Color], style: &ViewStyle, reduce: bool| { + recolor(&text(old), &text(new), style, reduce).starts() + }; + assert!(!starts(&[red], &[blue], &ViewStyle::default(), false)); + assert!(!starts( + &[red], + &[blue], + &transitioning(TransitionProps::OPACITY), + false + )); + assert!(!starts(&[red], &[blue], &colors, true)); + assert!(!starts(&[red], &[red], &colors, false)); + // Several colors take their new ones at once. + assert!(!starts(&[red, blue], &[blue], &colors, false)); + assert!(!starts(&[red], &[blue, red], &colors, false)); + assert!(!starts(&[], &[blue], &colors, false)); + // Decoration lines of another color are another color. + let mut underlined = text(&[blue]); + underlined.spans[0].style.decoration.underline = true; + assert_eq!(underlined.uniform_color(), Some(blue)); + underlined.spans[0].style.decoration_color = Some(red); + assert_eq!(underlined.uniform_color(), None); + assert!(!recolor(&text(&[red]), &underlined, &colors, false).starts()); + // A stopped transition leaves no color behind. + let mut live = Live::default(); + live.recolor(recolor(&text(&[red]), &text(&[blue]), &colors, false)); + live.recolor(recolor(&text(&[blue]), &text(&[red, blue]), &colors, false)); + assert!(live.idle() && live.text_color().is_none()); + } + + #[test] + fn a_text_color_steps_without_a_live() { + // iOS steps a `TextColor` on a display link of its own. + let (red, blue) = (Color::rgba(255, 0, 0, 255), Color::rgba(0, 0, 255, 255)); + let mut style = transitioning(TransitionProps::COLORS); + style.transition.as_mut().unwrap().delay = 50.0; + let mut color = TextColor::default(); + assert!(!color.running() && !color.step(0.0)); + color.recolor(recolor(&text(&[red]), &text(&[blue]), &style, false)); + assert!(color.running()); + // Delayed: the old color shows, nothing to draw again. + assert!(!color.step(1000.0) && !color.step(1040.0)); + assert_eq!(color.value(), Some(red)); + assert!(color.step(1110.0)); + assert_eq!(color.value(), Some(Color::rgba(153, 0, 102, 255))); + // The end draws once more, in the runs' own color. + assert!(color.step(1200.0)); + assert!(!color.running() && color.value().is_none()); + assert!(!color.step(1216.0)); + } } diff --git a/third-party/nsh/crates/nsh-core/src/websocket.rs b/third-party/nsh/crates/nsh-core/src/websocket.rs index 8ca1bdf43e..253fa45408 100644 --- a/third-party/nsh/crates/nsh-core/src/websocket.rs +++ b/third-party/nsh/crates/nsh-core/src/websocket.rs @@ -2,7 +2,9 @@ //! URL, capabilities and quotas before asking the platform to connect. //! Each task belongs to its calling engine; dropping it cancels the socket //! and prevents further events. Browser implementations use the page's -//! WebSocket API without changing the app's JavaScript classes. +//! WebSocket API without changing the app's JavaScript classes. The +//! `websocket` module pauses a task while the app's unread messages fill +//! their queue ([`WebSocketTask::pause`]) and resumes it as the app reads. /// A text or binary WebSocket message. #[derive(Clone, Debug, PartialEq, Eq)] @@ -74,6 +76,12 @@ pub trait WebSocketTask: 'static { fn close(&mut self, code: u16, reason: &str) -> Result<(), String>; /// Bytes the host still has queued for sending. fn buffered_amount(&self) -> usize; + /// Stops (`true`) or resumes (`false`) delivering messages: the app's + /// unread messages fill their queue (back pressure). A transport that + /// can stop reading from the network does, so the peer is held back; + /// one that cannot keeps the messages until it resumes. Messages already + /// on their way may still arrive; the caller bounds what it keeps. + fn pause(&mut self, paused: bool); } /// A platform's WebSocket stack. The factory is shareable, while each diff --git a/third-party/nsh/crates/nsh-core/unitless.txt b/third-party/nsh/crates/nsh-core/unitless.txt index bd70af4b0e..326da50e02 100644 --- a/third-party/nsh/crates/nsh-core/unitless.txt +++ b/third-party/nsh/crates/nsh-core/unitless.txt @@ -65,6 +65,7 @@ canvas::CanvasOp::MiterLimit.0 # a ratio of th canvas::Num::Const.0 # a generic number (a JS value, a recorded slot) canvas::Num::get -> # a generic number (a JS value, a recorded slot) canvas::Num::get(slot) # a generic number (a JS value, a recorded slot) +canvas::ViewBoxFit::transform -> # a transform matrix (scales, and offsets in points) canvas::damage::DamageScan::damage(value) # a generic number (a JS value, a recorded slot) canvas::hit::point_in_path(slot) # a generic number (a JS value, a recorded slot) canvas::hit::point_in_stroke(slot) # a generic number (a JS value, a recorded slot) @@ -94,6 +95,12 @@ effects::compose_color_matrices -> # color compone filesystem::FileMetadata.atime_ms # time (milliseconds since the epoch) filesystem::FileMetadata.birthtime_ms # time (milliseconds since the epoch) filesystem::FileMetadata.mtime_ms # time (milliseconds since the epoch) +image::decoded::Natural::points(density) # a pixel density (device pixels per point) +image::decoded::Want.scale # a pixel density (device pixels per point) +image::decoded::Want::longest(density) # a pixel density (device pixels per point) +image::decoded::Want::natural(scale) # a pixel density (device pixels per point) +image::loader::Location::density -> # a pixel density (a file name's @2x) +image::loader::density -> # a pixel density (a file name's @2x) input::LONG_PRESS # time (milliseconds) input::Pinch::at -> # a scale factor (the rotation is typed) input::Press.long_delay # time (milliseconds) @@ -224,6 +231,7 @@ transform::TransformOp.value # a transform o transition::Extra.scale # a scale factor transition::Live::opacity -> # an opacity factor transition::Live::step(now) # time +transition::TextColor::step(now) # time element::ambient::animated_value(value) # an animated value (any quantity) element::ambient::font_scale -> # a font scale factor element::ambient::keyboard_duration -> # time @@ -327,6 +335,8 @@ element::js::JsHost::new_number(n) # a generi element::js::JsHost::number -> # a generic number (a JS value, a recorded slot) element::js::JsIn::as_date -> # time (a JS Date's milliseconds since the epoch) element::js::JsIn::as_number -> # a generic number (a JS value, a recorded slot) +element::js::JsPrimitive::Date.0 # time (a JS Date's milliseconds since the epoch) +element::js::JsPrimitive::Number.0 # a generic number (a JS value, a recorded slot) element::module::ConstantValue::Number.0 # a generic number (a module constant's JS value) element::responder::PanGesture.vx # a velocity, points per second element::responder::PanGesture.vy # a velocity, points per second diff --git a/third-party/nsh/crates/nsh-dev/src/protocol.rs b/third-party/nsh/crates/nsh-dev/src/protocol.rs index 9661ac0e89..39d1112e48 100644 --- a/third-party/nsh/crates/nsh-dev/src/protocol.rs +++ b/third-party/nsh/crates/nsh-dev/src/protocol.rs @@ -161,6 +161,11 @@ pub struct TestEnvironment { /// the embedding app's, which the test plays. #[serde(default, skip_serializing_if = "Option::is_none")] pub host_params: Option>, + /// Whether the app's sockets and WebSockets reach the host's network + /// (`open`'s `network: "live"`); otherwise the host refuses them, as + /// the test fails requests no route answers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub live_network: Option, } /// How a test moves its app's manual clock ([`TestEnvironment::clock`]; @@ -497,7 +502,10 @@ pub enum TestStep { /// How. change: ClockChange, }, - /// Waits until a view matches, or none does (`app.waitFor`). + /// Waits until a view matches, or none does (`app.waitFor`). On a + /// manual clock ([`TestEnvironment::clock`]) the wait moves it, a + /// display frame at a time, looking after each frame, by `timeout_ms` + /// at most, and fails once as much real time passed too. WaitFor { /// The view. selector: String, @@ -512,6 +520,14 @@ pub enum TestStep { /// Milliseconds at most. timeout_ms: u64, }, + /// What polling a condition waits for between looks on a manual clock + /// (`app.waitUntil`): moves the clock by `ms` (a display frame), then + /// waits for the next batch a moment; as [`TestStep::NextChange`] on + /// the system's clock. + NextFrame { + /// Milliseconds the clock moves by. + ms: u64, + }, /// The texts on screen, in order: a list of strings. Texts, /// How many views match: a number. @@ -697,7 +713,8 @@ pub enum SceneQuery { /// The view. id: u32, }, - /// Whether a native animation runs. + /// Whether a native animation runs that ends by itself (not one + /// looping forever). Animating, /// Where to press to answer the dialog shown: its button `button`, or /// outside it (`None`). diff --git a/third-party/nsh/crates/nsh-engine/src/access.rs b/third-party/nsh/crates/nsh-engine/src/access.rs index 5f16376226..6eda4dd0b6 100644 --- a/third-party/nsh/crates/nsh-engine/src/access.rs +++ b/third-party/nsh/crates/nsh-engine/src/access.rs @@ -11,10 +11,27 @@ use nsh_core::{ /// Redirects followed at most (as browsers). const MAX_REDIRECTS: usize = 20; -/// The message of a refused request. -pub(crate) fn refused(method: &str, url: &str) -> String { +/// A request (or a redirect's hop) the app's capabilities do not grant: +/// JS's `fetch` rejects with a `TypeError` whose `code` is +/// `ERR_ACCESS_DENIED`, `permission` `"network"` and `resource` the URL +/// (`nsh_js::Host::fetch_refused`); a Rust task's fails with the message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Refused { + /// Why, for people. + pub(crate) message: String, + /// The URL refused. + pub(crate) url: String, +} + +/// The refusal of `method` `url`. +pub(crate) fn refused(method: &str, url: &str) -> Refused { let host = nsh_core::access::parse_url(url).map_or_else(|| url.to_owned(), |u| u.host); - format!("fetch {method} {url} is not granted: add `{host}` to capabilities.network in nsh.json") + Refused { + message: format!( + "fetch {method} {url} is not granted: add `{host}` to capabilities.network in nsh.json" + ), + url: url.to_owned(), + } } /// A request the engine follows through its redirects. @@ -33,6 +50,8 @@ pub(crate) enum Next { Done(HttpHead), /// The request fails. Fail(String), + /// The redirect leads where the app may not go. + Refused(Refused), /// Send this request next (checked against the capabilities). Hop(HttpRequest), } @@ -75,7 +94,7 @@ pub(crate) fn next(access: &Access, followed: &mut Followed, mut response: HttpH return Next::Fail("cannot follow a redirect with a streamed request body".to_owned()); } if !access.allows_http(&request.method, &url) { - return Next::Fail(refused(&request.method, &url)); + return Next::Refused(refused(&request.method, &url)); } request.url = url; followed.hops += 1; @@ -151,11 +170,13 @@ mod tests { ); assert!(!hop.follow_redirects && hop.body.is_empty() && hop.headers.is_empty()); // Out of it: refused. - let Next::Fail(err) = next(&access, &mut followed, redirect(307, "https://evil.io/")) + let Next::Refused(refused) = + next(&access, &mut followed, redirect(307, "https://evil.io/")) else { panic!("expected a refusal") }; - assert!(err.contains("`evil.io`")); + assert!(refused.message.contains("`evil.io`")); + assert_eq!(refused.url, "https://evil.io/"); // A streamed body is not sent again. followed.request.body = HttpBody::Stream; followed.request.method = "PUT".into(); diff --git a/third-party/nsh/crates/nsh-engine/src/browser.rs b/third-party/nsh/crates/nsh-engine/src/browser.rs index 74bad23eda..6a43d03b3e 100644 --- a/third-party/nsh/crates/nsh-engine/src/browser.rs +++ b/third-party/nsh/crates/nsh-engine/src/browser.rs @@ -42,6 +42,9 @@ pub struct Services { pub tokens: Arc, /// The initial theme. pub theme: ThemeTokens, + /// The engine's clock, which the host's timers and `Date` read: a + /// test's manual one moves only when the test moves it. + pub clock: nsh_core::time::Clock, } impl Default for Services { @@ -55,6 +58,7 @@ impl Default for Services { environment: Default::default(), tokens: Default::default(), theme: Default::default(), + clock: Default::default(), } } } @@ -109,6 +113,31 @@ pub trait Host { fn host_event(&self, name: &str, data: nsh_core::JsonValue); /// Complete an embedding host call. fn host_reply(&self, id: u64, result: Result); + /// When the earliest timer the host keeps on the engine's clock is + /// due. A host whose timers run in the browser's event loop keeps none. + fn next_timer(&self) -> Option { + None + } + /// Run the timers due at `now`. + fn run_timers(&self, now: Instant) { + let _ = now; + } + /// Move what JavaScript asked of the requests it routes through the + /// engine's network (`fetch` in a test session) into `out`, in order. + fn take_network(&self, out: &mut Vec<(u32, crate::FetchOp)>) { + let _ = out; + } + /// Deliver what happened to request `id`. + fn fetch_event(&self, id: u32, event: FetchEvent) { + let _ = (id, event); + } + /// Fail request `id`, which the app's capabilities do not grant: + /// `message` says why, `url` is the URL refused (`fetch` rejects with + /// `code` `ERR_ACCESS_DENIED`). + fn fetch_refused(&self, id: u32, message: &str, url: &str) { + let _ = url; + self.fetch_event(id, FetchEvent::Failed(message.to_owned())); + } /// The reason the host stopped, if it did. fn fatal(&self) -> Option; /// Stop the host and cancel pending callbacks and promises. @@ -193,6 +222,7 @@ impl Script { Default::default(), ) .with_websocket(system.websocket) + .with_refuse_connections(system.refuse_connections) .with_filesystem(system.filesystem) .with_os(environment.os); script.0.initialize(Services { @@ -204,6 +234,8 @@ impl Script { environment: *environment, tokens, theme, + // Startup runs on the engine's clock. + clock: nsh_core::time::current(), })?; Ok((script, 0)) })(); @@ -261,12 +293,14 @@ impl Script { pub(crate) fn has_posted(&self) -> bool { self.0.has_posted() } - // Browser timers and promise jobs run in the browser event loop. Each - // callback schedules the local engine; no second timer queue is kept. + // Browser timers and promise jobs run in the browser event loop, except + // those of a realm on a manual clock, which the host keeps. pub(crate) fn next_timer(&self) -> Option { - None + self.0.next_timer() + } + pub(crate) fn run_timers(&self, now: Instant) { + self.0.run_timers(now); } - pub(crate) fn run_timers(&self, _: Instant) {} pub(crate) fn run_system(&self) {} pub(crate) fn wants_frame(&self) -> bool { self.0.wants_frame() @@ -286,8 +320,15 @@ impl Script { pub(crate) fn take_fonts(&self, out: &mut Vec) { self.0.take_fonts(out); } - pub(crate) fn take_network(&self, _: &mut NetworkQueue) {} - pub(crate) fn fetch_event(&self, _: u32, _: FetchEvent) {} + pub(crate) fn take_network(&self, out: &mut NetworkQueue) { + self.0.take_network(&mut out.ops); + } + pub(crate) fn fetch_event(&self, id: u32, event: FetchEvent) { + self.0.fetch_event(id, event); + } + pub(crate) fn fetch_refused(&self, id: u32, message: &str, url: &str) { + self.0.fetch_refused(id, message, url); + } pub(crate) fn collect_garbage(&self) {} pub(crate) fn fatal(&self) -> Option { self.0.fatal() diff --git a/third-party/nsh/crates/nsh-engine/src/lib.rs b/third-party/nsh/crates/nsh-engine/src/lib.rs index 1901835db7..804ced759d 100644 --- a/third-party/nsh/crates/nsh-engine/src/lib.rs +++ b/third-party/nsh/crates/nsh-engine/src/lib.rs @@ -162,11 +162,6 @@ pub struct Bundle { /// them without asking the platform); those missing are asked of /// [`MountSink::load_chunk`]. Shared by the bundle's clones. pub chunks: Arc>, - /// The sandbox's hardening as bytecode (`nsh_js::compile_harden`), - /// for a runtime without QuickJS' parser (the web runtime of published - /// apps); `None`: compiled when the policy hardens. Set it with - /// [`Bundle::with_harden_bytecode`]. - pub harden_bytecode: Option>>, /// The app's id (`nsh.json` `"id"`; a signed package's verified one): /// it names where the app keeps its data (`nsh/storage`). pub app_id: Option, @@ -308,7 +303,6 @@ impl Bundle { policy: Policy::default(), base_url: None, chunks: Arc::default(), - harden_bytecode: None, app_id: None, system_paths: nsh_core::access::TrustedPaths::default(), package: None, @@ -323,7 +317,6 @@ impl Bundle { policy: Policy::default(), base_url: None, chunks: Arc::default(), - harden_bytecode: None, app_id: None, system_paths: nsh_core::access::TrustedPaths::default(), package: None, @@ -360,17 +353,6 @@ impl Bundle { self } - /// Hardens with `bytes` ([`Bundle::harden_bytecode`]). - /// - /// # Safety - /// - /// As [`Bundle::bytecode`]: `bytes` comes from `nsh_js::compile_harden` - /// of this runtime's `BYTECODE_VERSION`, from a trusted source. - pub unsafe fn with_harden_bytecode(mut self, bytes: Vec) -> Self { - self.harden_bytecode = Some(Arc::new(bytes)); - self - } - /// Replaces the policy. pub fn with_policy(mut self, policy: Policy) -> Self { self.policy = policy; @@ -667,6 +649,17 @@ pub trait Network: Send + Sync + 'static { pub trait Assets: Send + Sync + 'static { /// The bytes of `uri`. Blocking; called on the engine thread. fn read(&self, uri: &str) -> Result, String>; + + /// Where `asset:/` URIs resolve, when it is a directory or a URL the + /// image loader can read itself: the core's image loads ([`svg`]s) + /// then share the platform's cache entries. `None`: through + /// [`Assets::read`]. + /// + /// [`svg`]: nsh_core::element::svg + #[cfg(not(target_arch = "wasm32"))] + fn base(&self) -> Option { + None + } } /// Engine settings. @@ -682,6 +675,11 @@ pub struct EngineConfig { pub network: Option>, /// The host transport for WebSockets, including the browser WebSocket API. pub websocket: Option>, + /// Refuses the app's connections (`net` sockets and both WebSocket + /// APIs) with the error it gives for what they connect to, before they + /// reach the network: a test's, which reaches it only when the test + /// asks (`nsh test`'s `network: "live"`). `None`: they connect. + pub refuse_connections: Option, /// App-private host filesystem service (for browser storage). pub filesystem: Option>, /// Source of bundled files such as fonts; without one, they fail to load. @@ -819,6 +817,7 @@ impl EngineConfig { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, #[cfg(not(target_arch = "wasm32"))] @@ -906,6 +905,7 @@ enum Message { HintObservation(nsh_core::overlay::HintObservation), CancelTouch(u32), Scroll(NodeId, Point, u32), + Kept(Vec), InputScroll(NodeId, Point, Size, Size), Zoom(NodeId, f32), ComponentSize(NodeId, Size), @@ -1017,6 +1017,13 @@ impl EngineControl { let _ = self.tx.send(Message::Scroll(id, offset, applied)); } + /// Keeps the virtual list rows holding `nodes` rendered wherever they + /// scroll (`Core::set_kept`): the ends of a text selection. Replaces + /// the last call's; none releases them. + pub fn keep_rendered(&self, nodes: Vec) { + let _ = self.tx.send(Message::Kept(nodes)); + } + /// Reports how far a multiline input's text scrolled: its offset, the /// size of the text it scrolls and of its viewport. Sent while a /// built-in bar takes the place of its indicator @@ -1501,6 +1508,18 @@ impl Driver { core.set_max_nodes(config.limits.max_nodes); } core.set_access(access.clone()); + #[cfg(not(target_arch = "wasm32"))] + core.set_image_context(nsh_core::image::loader::Context { + asset_base: config.assets.as_ref().and_then(|a| a.base()), + read_asset: config.assets.clone().map(|assets| { + nsh_core::image::loader::ReadAsset::new(move |uri: &str| assets.read(uri)) + }), + access: access.clone(), + // The same cache as the platform's ([`Engine::image_cache`]). + disk: config.cache.open_images(config.app.app_id()), + // The platform's, if it set one ([`nsh_core::image::loader::set_transport`]). + transport: None, + }); core.set_storage(app_storage(&config)); core.set_clipboard(config.clipboard.clone()); core.set_picker(config.picker.clone()); @@ -1642,6 +1661,7 @@ impl Driver { script::SystemConfig { access: (*access).clone(), websocket: config.websocket.clone(), + refuse_connections: config.refuse_connections.clone(), filesystem: config.filesystem.clone(), data_dir: config .data_dir @@ -2063,7 +2083,7 @@ impl Net { let mut tasks = std::mem::take(&mut self.tasks); for (id, request) in tasks.drain(..) { if let Err(refused) = self.request(Requester::Task(id), request) { - core.fetch_event(id, FetchEvent::Failed(refused)); + core.fetch_event(id, FetchEvent::Failed(refused.message)); } } self.tasks = tasks; @@ -2074,7 +2094,7 @@ impl Net { match op { FetchOp::Start(request) => { if let Err(refused) = self.request(who, request) { - script.fetch_event(id, FetchEvent::Failed(refused)); + script.fetch_refused(id, &refused.message, &refused.url); } } FetchOp::Send(chunk) => { @@ -2105,7 +2125,7 @@ impl Net { } /// Makes `who`'s request, if the app may (else why not). - fn request(&mut self, who: Requester, request: HttpRequest) -> Result<(), String> { + fn request(&mut self, who: Requester, request: HttpRequest) -> Result<(), access::Refused> { if self.access.is_unrestricted() { self.start(who, request, None); } else if !self.access.allows_http(&request.method, &request.url) { @@ -2146,24 +2166,35 @@ impl Net { /// An event of hop `hop` of request `id`: what JS is told, if anything /// (`None` for events of hops left behind, and redirects the engine - /// follows). - fn event(&mut self, id: Requester, hop: u64, event: FetchEvent) -> Option { + /// follows; `Err` for a redirect the app may not follow). + fn event( + &mut self, + id: Requester, + hop: u64, + event: FetchEvent, + ) -> Option> { if self.network.is_none() { - return Some(event); + return Some(Ok(event)); } let inflight = self.inflight.get_mut(&id).filter(|i| i.hop == hop)?; match event { FetchEvent::Head(head) => { let Some(followed) = &mut inflight.followed else { - return Some(FetchEvent::Head(head)); + return Some(Ok(FetchEvent::Head(head))); }; match access::next(&self.access, followed, head) { - access::Next::Done(head) => Some(FetchEvent::Head(head)), + access::Next::Done(head) => Some(Ok(FetchEvent::Head(head))), access::Next::Fail(message) => { if let Some(i) = self.inflight.remove(&id) { i.task.cancel(); } - Some(FetchEvent::Failed(message)) + Some(Ok(FetchEvent::Failed(message))) + } + access::Next::Refused(refused) => { + if let Some(i) = self.inflight.remove(&id) { + i.task.cancel(); + } + Some(Err(refused)) } access::Next::Hop(request) => { let i = self.inflight.remove(&id)?; @@ -2176,9 +2207,9 @@ impl Net { } FetchEvent::End | FetchEvent::Failed(_) => { self.inflight.remove(&id); - Some(event) + Some(Ok(event)) } - event => Some(event), + event => Some(Ok(event)), } } } @@ -2684,6 +2715,8 @@ fn ui_environment(env: &Environment, window: Size) -> nsh_core::element: apple_keyboard: env.apple_keyboard, terminal_keyboard: env.terminal_keyboard, host: env.host, + screen_color: env.screen_color, + file_requests: env.file_requests, } } @@ -2841,6 +2874,7 @@ fn handle(script: Option<&Script>, state: &mut State, message: event.pending = true; } } + Message::Kept(nodes) => state.core.set_kept(&nodes), Message::InputScroll(id, offset, content, viewport) => { if state.core.accepts_native_input(id) { state.core.set_input_scroll(id, offset, content, viewport); @@ -2906,22 +2940,23 @@ fn handle(script: Option<&Script>, state: &mut State, message: } Message::FontsChanged => state.core.fonts_changed(), Message::Remount => state.core.remount(), - Message::Fetch(who, hop, event) => { - if let Some(event) = state.net.event(who, hop, event) { - match who { - Requester::Js(id) => { - if let Some(script) = script { - script.fetch_event(id, event); - } - } - Requester::Task(id) => { - if !state.core.fetch_event(id, event) { - state.net.cancel(who); - } + Message::Fetch(who, hop, event) => match (who, state.net.event(who, hop, event)) { + (_, None) => {} + (Requester::Js(id), Some(event)) => { + if let Some(script) = script { + match event { + Ok(event) => script.fetch_event(id, event), + Err(refused) => script.fetch_refused(id, &refused.message, &refused.url), } } } - } + (Requester::Task(id), Some(event)) => { + let event = event.unwrap_or_else(|refused| FetchEvent::Failed(refused.message)); + if !state.core.fetch_event(id, event) { + state.net.cancel(who); + } + } + }, Message::Hot(update) => state.hot.push(update), Message::Chunk(chunk) => { #[cfg(not(target_arch = "wasm32"))] diff --git a/third-party/nsh/crates/nsh-engine/src/perf.rs b/third-party/nsh/crates/nsh-engine/src/perf.rs index 4fc7cd1875..369cae0a75 100644 --- a/third-party/nsh/crates/nsh-engine/src/perf.rs +++ b/third-party/nsh/crates/nsh-engine/src/perf.rs @@ -464,33 +464,49 @@ fn cpu_time() -> Option { None } -/// The physical footprint (`proc_pid_rusage`): the dirty and compressed -/// memory the process owns, what Activity Monitor shows and what the system -/// judges it by under memory pressure (unlike the resident size, it leaves -/// out the frameworks' clean pages every process maps). +/// The physical footprint (`task_info`'s `TASK_VM_INFO`): the dirty and +/// compressed memory the process owns, what Activity Monitor shows and what +/// the system judges it by under memory pressure (unlike the resident size, +/// it leaves out the frameworks' clean pages every process maps). Mach's +/// `task_info` is public on every Apple platform; libproc's +/// `proc_pid_rusage`, which reads the same number, has no header in the iOS +/// SDK, and App Store review can reject iOS apps for calls outside it. #[cfg(target_vendor = "apple")] fn memory() -> Option { + /// `struct task_vm_info` (`mach/task_info.h`) as far as + /// `phys_footprint`, the fields `TASK_VM_INFO_REV1_COUNT` covers; the + /// `libc` crate does not declare it. + #[repr(C)] + struct TaskVmInfo { + /// `virtual_size` to `compressed_lifetime`: two `integer_t`s and + /// seventeen `mach_vm_size_t`s, 144 bytes. + before: [u64; 18], + phys_footprint: u64, + } unsafe extern "C" { - // libSystem's (libproc) on every Apple platform; the `libc` crate - // declares it for macOS only. - fn proc_pid_rusage( - pid: libc::c_int, - flavor: libc::c_int, - buffer: *mut libc::rusage_info_t, - ) -> libc::c_int; + // The task's own port (`mach_init.h`, whose `mach_task_self()` is a + // macro reading it); the `libc` crate wraps it for macOS only. + static mach_task_self_: libc::mach_port_t; } - let mut info = std::mem::MaybeUninit::::uninit(); - // SAFETY: `info` is a buffer of exactly the layout `RUSAGE_INFO_V2` - // names, the only thing the call writes. + const TASK_VM_INFO: libc::task_flavor_t = 22; + const _: () = assert!(std::mem::size_of::() == 152); + let words = std::mem::size_of::() / std::mem::size_of::(); + let mut count = words as libc::mach_msg_type_number_t; + let mut info = std::mem::MaybeUninit::::uninit(); + // SAFETY: `mach_task_self_` is set before any code runs and never + // changes; `info` has room for `count` words, the most the call writes, + // and `count` says how many it wrote. let status = unsafe { - proc_pid_rusage( - std::process::id() as libc::c_int, - libc::RUSAGE_INFO_V2, - info.as_mut_ptr().cast::(), + libc::task_info( + mach_task_self_, + TASK_VM_INFO, + info.as_mut_ptr().cast::(), + &mut count, ) }; - // SAFETY: a zero return means the call filled the buffer. - (status == 0).then(|| unsafe { info.assume_init() }.ri_phys_footprint) + // SAFETY: success with every word asked for filled the whole buffer. + (status == libc::KERN_SUCCESS && count as usize == words) + .then(|| unsafe { info.assume_init() }.phys_footprint) } /// The resident set (`/proc/self/statm`'s second field, in pages). @@ -609,4 +625,27 @@ mod tests { #[cfg(unix)] assert!(sampler.sample().cpu.is_some()); } + + /// `task_info`'s footprint is the one `proc_pid_rusage` reported (the + /// two are read a moment apart, so they may differ by what was mapped + /// in between). + #[cfg(target_os = "macos")] + #[test] + fn footprint_matches_proc_pid_rusage() { + let ours = super::memory().expect("task_info"); + let mut info = std::mem::MaybeUninit::::uninit(); + // SAFETY: `info` is a buffer of exactly the layout `RUSAGE_INFO_V2` + // names, the only thing the call writes. + let status = unsafe { + libc::proc_pid_rusage( + std::process::id() as libc::c_int, + libc::RUSAGE_INFO_V2, + info.as_mut_ptr().cast::(), + ) + }; + assert_eq!(status, 0); + // SAFETY: a zero return means the call filled the buffer. + let theirs = unsafe { info.assume_init() }.ri_phys_footprint; + assert!(ours.abs_diff(theirs) < 8 << 20, "{ours} vs {theirs}"); + } } diff --git a/third-party/nsh/crates/nsh-engine/src/script.rs b/third-party/nsh/crates/nsh-engine/src/script.rs index cce60f839e..99b8ec357e 100644 --- a/third-party/nsh/crates/nsh-engine/src/script.rs +++ b/third-party/nsh/crates/nsh-engine/src/script.rs @@ -8,6 +8,7 @@ pub(crate) struct SystemConfig { pub(crate) access: nsh_core::access::Access, pub(crate) websocket: Option>, + pub(crate) refuse_connections: Option, pub(crate) filesystem: Option>, pub(crate) data_dir: Option, pub(crate) paths: nsh_core::access::TrustedPaths, @@ -86,7 +87,6 @@ mod imp { let options = HostOptions { limits, harden: bundle.policy.harden, - harden_bytecode: bundle.harden_bytecode.clone(), ui_sets: ui_sets.to_vec(), posts, base_url: bundle.base_url.as_deref().map(str::to_owned), @@ -105,6 +105,7 @@ mod imp { host.set_system_access(system.access, system.data_dir); host.set_system_paths(system.paths); host.set_websocket_transport(system.websocket); + host.set_refuse_connections(system.refuse_connections); host.set_filesystem(system.filesystem); host.set_system_wake(system.wake); host.set_host_bridge(system.host); @@ -336,6 +337,10 @@ mod imp { self.host.fetch_event(id, event); } + pub(crate) fn fetch_refused(&self, id: RequestId, message: &str, url: &str) { + self.host.fetch_refused(id, message, url); + } + pub(crate) fn collect_garbage(&self) { self.host.collect_garbage(); } @@ -508,6 +513,10 @@ mod stub { match *self {} } + pub(crate) fn fetch_refused(&self, _: u32, _: &str, _: &str) { + match *self {} + } + pub(crate) fn collect_garbage(&self) { match *self {} } diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs b/third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs index a0e3dc9f5b..502e40050c 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/canvas.rs @@ -1,4 +1,4 @@ -//! `nsh/canvas` end to end: what a `draw` records (the generated methods and +//! `Canvas` end to end: what a `draw` records (the generated methods and //! properties, and the hand-written ones) as the core decodes it. use std::time::Duration; @@ -72,6 +72,7 @@ pub(crate) fn start(app: &str) -> (Engine, crossbeam_channel::Receiver (Engine, Receiver) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -229,6 +230,7 @@ fn start_app_with(app: &str, header_heights: (Points, Points)) -> (Engine, Recei trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/components.rs b/third-party/nsh/crates/nsh-engine/tests/engine/components.rs index 0d300df25d..ce6d3c86ec 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/components.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/components.rs @@ -71,6 +71,7 @@ pub(crate) fn start_with( trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -744,6 +745,102 @@ fn slots_children_and_events() { ); } +const SLOT_LAYOUT: &str = r#"import { mount, Text, View } from "nsh"; +import { Field } from "test/ui"; + +mount(() => ( + + Grow} /> + End} /> + Low} /> + Half} /> + OneTwo} hint={Hint} /> + +)); +"#; + +/// The app's element in a slot the component places as it is lays out as +/// an item of the component's layout, as its own child would (`Field`'s +/// label in a 300 × 40 row before a 50 × 20 control): `flex: 1` grows +/// along the row, `marginLeft: "auto"` pushes it to the control, +/// `alignSelf` aligns it in the row, a percentage is of the row. Its holder +/// takes its box and it sits at the holder's origin. A slot the component +/// styles (`hint`, padded) and a slot of several elements stay boxes of +/// their own, laid out as columns. +#[test] +fn slot_content_lays_out_in_the_components_layout() { + let (_engine, rx) = start(SLOT_LAYOUT); + let mut batches = vec![ + rx.recv_timeout(Duration::from_secs(5)) + .expect("first frame"), + ]; + while let Ok(batch) = rx.recv_timeout(Duration::from_millis(200)) { + batches.push(batch); + } + let id = |wanted| { + batches + .iter() + .find_map(|b| text_ids(b, wanted)) + .unwrap_or_else(|| panic!("{wanted} rendered")) + }; + let parent_of = |child| { + batches + .iter() + .find_map(|b| { + b.items.iter().find_map(|i| match i { + MountItem::Insert { + parent, child: c, .. + } if *c == child => Some(*parent), + _ => None, + }) + }) + .expect("inserted") + }; + let frame = |id| frame_of(&batches, id).expect("a frame"); + // (x, y, width, height) of the holder of `wanted`, and its own frame. + let boxes = |wanted| { + let text = id(wanted); + let b = |f: nsh_core::Bounds| { + (f.origin.x.0, f.origin.y.0, f.size.width.0, f.size.height.0) + }; + (b(frame(parent_of(text))), b(frame(text))) + }; + + let (holder, text) = boxes("Grow"); + assert_eq!((holder.0, holder.2), (0.0, 250.0), "grows to the control"); + assert_eq!((text.0, text.1, text.2), (0.0, 0.0, 250.0)); + let close = |a: f32, b: f32| (a - b).abs() < 0.01; + let (holder, text) = boxes("End"); + assert!(holder.0 > 0.0, "{holder:?}"); + assert!( + close(holder.0 + holder.2, 250.0), + "pushed to the control: {holder:?}" + ); + assert_eq!((text.0, text.1), (0.0, 0.0)); + let (holder, _) = boxes("Low"); + assert!( + close(holder.1 + holder.3, 40.0), + "at the row's bottom: {holder:?}" + ); + let (holder, text) = boxes("Half"); + assert_eq!((holder.2, text.2), (150.0, 150.0), "half the row"); + + // Several elements: a column of their own. + let (one, _) = boxes("One"); + let (two, _) = boxes("Two"); + assert_eq!(one, two, "one holder"); + let (one, two) = (frame(id("One")), frame(id("Two"))); + assert_eq!(two.origin.y.0, one.origin.y.0 + one.size.height.0); + // Styled by the component: its padded box, after the control. + let (holder, text) = boxes("Hint"); + let label = one.size.width.0.max(two.size.width.0); + assert!( + close(holder.0, label + 50.0), + "after the control: {holder:?}" + ); + assert_eq!(text.0, 8.0, "in the holder's padding"); +} + const METHODS: &str = r#"import { mount, signal, Text, View } from "nsh"; import { Stepper, type StepperRef } from "test/ui"; @@ -1014,6 +1111,53 @@ fn runtime_modules() { assert_eq!(focused, Some(note)); } +const IMAGE_DENIED: &str = r#"import { mount, signal, Text } from "nsh"; +import { Image } from "nsh"; + +const shown = signal("waiting"); +const settled = (p: Promise) => + p.then( + () => "ok", + (e) => [e.name, e.code, e.permission, e.resource].join(" "), + ); +setTimeout(async () => { + const results = await Promise.all([ + settled(Image.getSize("https://evil.io/a.png")), + settled(Image.prefetch("https://evil.io/b.png")), + ]); + shown.set(results.join(" | ")); +}, 0); + +mount(() => {shown()}); +"#; + +#[test] +fn image_loads_stay_within_the_capabilities() { + let (_engine, rx) = start_with( + IMAGE_DENIED, + Environment::default(), + Some(r#"{ "network": { "hosts": ["example.com"] } }"#), + None, + ); + let wanted = "Error ERR_ACCESS_DENIED network https://evil.io/a.png | \ + Error ERR_ACCESS_DENIED network https://evil.io/b.png"; + let mut asked = false; + let batches = until(&rx, |batch| { + asked |= batch.items.iter().any(|i| { + matches!( + i, + MountItem::Command { + command: Command::ImageInfo { .. }, + .. + } + ) + }); + shows(batch, wanted) + }); + assert!(batches.iter().any(|b| shows(b, wanted))); + assert!(!asked, "a refused image load reached the platform"); +} + const MEASURE: &str = r#"import { mount, signal, Scroll, Text, View, type ViewRef } from "nsh"; const shown = signal("waiting"); @@ -1166,12 +1310,61 @@ fn the_clipboard_round_trips_through_the_platform() { ); } +/// Every clipboard function, as each settles: `ok` or the error's class, +/// `code`, `permission` and `resource`. +const CLIPBOARD_DENIED: &str = r#"import { mount, signal, Text } from "nsh"; +import { Clipboard } from "nsh"; + +const shown = signal("waiting"); +const settled = (p: Promise) => + p.then( + () => "ok", + (e) => [e.name, e.code, e.permission, JSON.stringify(e.resource)].join(" "), + ); +setTimeout(async () => { + const results = await Promise.all([ + settled(Clipboard.writeText("copied")), + settled(Clipboard.readText()), + settled(Clipboard.hasText()), + settled(Clipboard.writeImage("data:image/png;base64,aGk=")), + settled(Clipboard.readImage()), + settled(Clipboard.hasImage()), + settled(Clipboard.readFiles()), + ]); + shown.set(results.join(" | ")); +}, 0); + +mount(() => {shown()}); +"#; + #[test] fn the_clipboard_needs_its_capability() { - let (engine, rx) = start_with(CLIPBOARD, Environment::default(), Some("{}"), None); - // Nothing reaches the platform, and reads find nothing. - let commands = serve_clipboard(&engine, &rx, "false false "); - assert!(commands.is_empty(), "{commands:?}"); + let (_engine, rx) = start_with(CLIPBOARD_DENIED, Environment::default(), Some("{}"), None); + // Every call rejects, and nothing reaches the platform. + let wanted = [r#"Error ERR_ACCESS_DENIED clipboard """#; 7].join(" | "); + let mut commands = 0; + let batches = until(&rx, |batch| { + commands += batch + .items + .iter() + .filter(|i| { + matches!( + i, + MountItem::Command { + id: ROOT_ID, + command: Command::SetClipboard(_) + | Command::ReadClipboard { .. } + | Command::SetClipboardImage(_) + | Command::ReadClipboardImage { .. } + | Command::ReadClipboardFiles { .. }, + } + ) + }) + .count(); + shows(batch, &wanted) + }); + assert!(batches.iter().any(|b| shows(b, &wanted)), "{wanted}"); + assert_eq!(commands, 0, "a refused call reached the platform"); let (engine, rx) = start_with( CLIPBOARD, Environment::default(), @@ -1266,6 +1459,109 @@ fn clipboard_images_and_files_reach_js() { ); } +/// The image functions, as each settles: `ok` and the value, or the +/// error's class and `code`. +const CLIPBOARD_IMAGE_TEXT_ONLY: &str = r#"import { mount, signal, Text } from "nsh"; +import { Clipboard } from "nsh"; + +const shown = signal("waiting"); +const settled = (p: Promise) => + p.then( + (value) => `ok ${value}`, + (e) => `${e.name} ${e.code}`, + ); +setTimeout(async () => { + const results = await Promise.all([ + settled(Clipboard.writeImage("data:image/png;base64,aGk=")), + settled(Clipboard.readImage()), + settled(Clipboard.hasImage()), + ]); + shown.set(results.join(" | ")); +}, 0); + +mount(() => {shown()}); +"#; + +#[test] +fn clipboard_images_are_unavailable_where_the_clipboard_holds_text_only() { + let (_engine, rx) = start_in( + CLIPBOARD_IMAGE_TEXT_ONLY, + Environment { + os: nsh_engine::Os::Android, + ..Environment::default() + }, + ); + // Writing and reading reject, `hasImage` answers no; nothing reaches + // the platform. + let wanted = "Error ERR_FEATURE_UNAVAILABLE_ON_PLATFORM | Error ERR_FEATURE_UNAVAILABLE_ON_PLATFORM | ok false"; + let mut commands = 0; + let batches = until(&rx, |batch| { + commands += batch + .items + .iter() + .filter(|i| { + matches!( + i, + MountItem::Command { + id: ROOT_ID, + command: Command::SetClipboardImage(_) | Command::ReadClipboardImage { .. }, + } + ) + }) + .count(); + shows(batch, wanted) + }); + assert!(batches.iter().any(|b| shows(b, wanted)), "{wanted}"); + assert_eq!(commands, 0, "an image call reached the platform"); +} + +const CLIPBOARD_IMAGE_TOO_LARGE: &str = r#"import { mount, signal, Text } from "nsh"; +import { Clipboard } from "nsh"; + +const shown = signal("waiting"); +setTimeout(async () => { + const has = await Clipboard.hasImage(); + const read = await Clipboard.readImage().then( + () => "resolved", + (e) => `${e.name} ${e.code} ${e.message}`, + ); + shown.set(`${has} ${read}`); +}, 0); + +mount(() => {shown()}); +"#; + +#[test] +fn clipboard_images_over_the_limit_reject() { + let (engine, rx) = start(CLIPBOARD_IMAGE_TOO_LARGE); + let control = engine.control(); + // A 50 MiB image: the platform answers its size, not its bytes. + let size = 50 << 20; + let wanted = format!( + "true Error ERR_NSH_QUOTA_EXCEEDED Clipboard.readImage: the clipboard's image is {size} bytes (50 MB), over the 48 MB an app reads" + ); + let batches = until(&rx, |batch| { + for item in &batch.items { + if let MountItem::Command { + id: ROOT_ID, + command: Command::ReadClipboardImage { id, has_only }, + } = item + { + let mut payload = vec![ + ("id", PayloadValue::Number(*id as f64)), + ("has", PayloadValue::Bool(true)), + ]; + if !has_only { + payload.push(("tooLarge", PayloadValue::Number(size as f64))); + } + control.dispatch_event(ROOT_ID, EventKind::Clipboard, EventPayload::Map(payload)); + } + } + shows(batch, &wanted) + }); + assert!(batches.iter().any(|b| shows(b, &wanted)), "{wanted}"); +} + const PICKER: &str = r#"import { mount, signal, Text } from "nsh"; import { Picker } from "nsh"; @@ -1331,6 +1627,72 @@ fn picked_images_reach_js_as_files() { ); } +const PICK: &str = r#"import { mount, signal, Text } from "nsh"; +import { Picker } from "nsh"; + +const shown = signal("waiting"); +setTimeout(() => { + Picker.pickImages().then( + (images) => shown.set(`picked ${images.length}`), + (e) => shown.set(`rejected ${e.name} ${e.code} ${e.permission}`), + ); +}, 0); + +mount(() => {shown()}); +"#; + +#[test] +fn the_picker_needs_its_capability() { + let (_engine, rx) = start_with(PICK, Environment::default(), Some("{}"), None); + let wanted = "rejected Error ERR_ACCESS_DENIED picker"; + let mut asked = false; + let batches = until(&rx, |batch| { + asked |= batch.items.iter().any(|i| { + matches!( + i, + MountItem::Command { + command: Command::PickImages { .. }, + .. + } + ) + }); + shows(batch, wanted) + }); + assert!(batches.iter().any(|b| shows(b, wanted))); + assert!(!asked, "a refused pick reached the platform"); +} + +#[test] +fn a_cancelled_pick_resolves_empty() { + let (engine, rx) = start_with( + PICK, + Environment::default(), + Some(r#"{ "picker": true }"#), + None, + ); + let control = engine.control(); + let batches = until(&rx, |batch| { + for item in &batch.items { + if let MountItem::Command { + id: ROOT_ID, + command: Command::PickImages { id, .. }, + } = item + { + control.dispatch_event( + ROOT_ID, + EventKind::Pick, + EventPayload::Picked { + id: *id, + files: Vec::new(), + }, + ); + } + } + shows(batch, "picked 0") + }); + assert!(batches.iter().any(|b| shows(b, "picked 0"))); +} + const INPUT_TRAITS: &str = r##"import { mount, Text } from "nsh"; import { Input } from "nsh/base"; @@ -1614,7 +1976,7 @@ const BACK: &str = r#"import { mount, signal, Text } from "nsh"; import { BackHandler } from "nsh/mobile"; const presses = signal(0); -BackHandler.onBackPress(() => { +BackHandler.on("press", () => { presses.update((p) => p + 1); // The first press is handled, the second one leaves the app. return presses() === 1; @@ -2848,30 +3210,40 @@ fn base_tabs_and_radio_groups_rove_the_keyboard_focus() { assert!(after.iter().any(|batch| shows(batch, log))); } -const BASE_COLLAPSIBLE: &str = r##"import { mount, signal, Text, View } from "nsh"; -import { Collapsible } from "nsh/base"; +const BASE_TOGGLE: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Toggle, ToggleGroup } from "nsh/base"; function App() { - const open = signal(false); - const count = signal(0); + const marks = signal(["b"]); + const locked = signal(false); + const log = signal([]); + const toggle = (value: string, label: string, disabled = false) => ( + { + log.set([...log(), `${value}:${next}`]); + marks.set(next ? [...marks(), value] : marks().filter((m) => m !== value)); + }} + > + + {label} + + ); return ( - {`Details ${count()}`}} - > - open.set(!open())} - > - Summary - - - count.set(count() + 1)} /> + + {toggle("a", "Bold")} + {toggle("b", "Italic")} + {toggle("c", "Strikethrough", true)} + {toggle("d", "Underline")} + + locked.set(!locked())} /> + {`Log: ${log().join(",")}`} ); } @@ -2879,28 +3251,34 @@ function App() { mount(App); "##; -/// `nsh/base`'s `Collapsible`: its children always, its `content` after -/// them only while the app's `open` holds, made once and kept, so what is in -/// it keeps its state and follows the app while collapsed; the trigger is -/// the app's, with the expanded state it gives screen readers. +/// `nsh/base`'s `Toggle` in a `ToggleGroup`: the toolbar role on the +/// group, the button role with its pressed state on each toggle alone, a +/// press asking for the other value while the app owns which are pressed, +/// `pressedStyle` and the app's mark inside following the toggle with +/// `group-aria-pressed:`, a disabled toggle (or group) reporting nothing, +/// and the toolbar's roving focus: one stop, at the pressed toggle, the +/// arrow keys moving the focus past the disabled toggle without pressing. #[test] -fn a_base_collapsible_places_its_content_while_open() { - let (engine, rx) = start(BASE_COLLAPSIBLE); - let first = rx - .recv_timeout(Duration::from_secs(5)) - .expect("first frame"); - assert!(shows(&first, "Summary")); - assert!( - !first - .items - .iter() - .any(|i| matches!(i, MountItem::Text { text, .. } if text.text.starts_with("Details"))), - "collapsed: no content" +fn a_base_toggle_reports_the_next_value_and_its_group_roves_without_pressing() { + use std::sync::Arc; + + use nsh_core::{ + AccessibilityRole, Checked, + keymap::{KeyMatch, Keymap, Keystroke}, + }; + + let (engine, rx) = start_in( + BASE_TOGGLE, + Environment { + os: nsh_engine::Os::MacOs, + ..Environment::default() + }, ); - let by_test_id = |test_id: &str| { - first - .items + let mut batches = until(&rx, |batch| shows(batch, "Log: ")); + let id = |batches: &[MountBatch], test_id: &str| { + batches .iter() + .flat_map(|b| b.items.iter()) .find_map(|i| match i { MountItem::ViewProps { id, props } if props.test_id.as_deref() == Some(test_id) => { Some(*id) @@ -2909,10 +3287,286 @@ fn a_base_collapsible_places_its_content_while_open() { }) .expect(test_id) }; - let (region, trigger, bump) = ( - by_test_id("region"), - by_test_id("trigger"), - by_test_id("bump"), + let [group, a, b, c, d, lock] = ["marks", "a", "b", "c", "d", "lock"].map(|t| id(&batches, t)); + let props = |batches: &[MountBatch], view| { + batches + .iter() + .rev() + .flat_map(|b| b.items.iter().rev()) + .find_map(|i| match i { + MountItem::ViewProps { id, props } if *id == view => Some(props.clone()), + _ => None, + }) + }; + let style = |batches: &[MountBatch], view| { + batches + .iter() + .rev() + .flat_map(|b| b.items.iter().rev()) + .find_map(|i| match i { + MountItem::ViewStyle { id, style } if *id == view => Some(style.clone()), + _ => None, + }) + .unwrap_or_default() + }; + // The first view inside `parent`: the app's mark. + let mark = |batches: &[MountBatch], parent: u32| { + let views: Vec = batches + .iter() + .flat_map(|b| b.items.iter()) + .filter_map(|i| match i { + MountItem::Create { + id, + kind: nsh_core::ViewKind::View, + } => Some(*id), + _ => None, + }) + .collect(); + batches + .iter() + .flat_map(|b| b.items.iter()) + .find_map(|i| match i { + MountItem::Insert { + parent: p, child, .. + } if *p == parent && views.contains(child) => Some(*child), + _ => None, + }) + .expect("the mark") + }; + let pressed = + |batches: &[MountBatch], view| props(batches, view).unwrap().accessibility.state.pressed; + let stops = |batches: &[MountBatch], views: &[u32]| -> Vec { + views + .iter() + .copied() + .filter(|&view| props(batches, view).and_then(|p| p.focusable) == Some(true)) + .collect() + }; + let keymap = |batches: &[MountBatch]| -> Option> { + batches + .iter() + .rev() + .flat_map(|b| b.items.iter().rev()) + .find_map(|i| match i { + MountItem::Keymap(keymap) => Some(keymap.clone()), + _ => None, + }) + }; + let bound = |keymap: &Keymap, keystroke: &str| { + let stroke = Keystroke::parse(keystroke, nsh_engine::Os::MacOs).unwrap(); + match keymap.find(stroke, false, false) { + KeyMatch::Fire(id) => Some(id), + _ => None, + } + }; + let focus_calls = |batches: &[MountBatch]| -> Vec { + batches + .iter() + .flat_map(|b| b.items.iter()) + .filter_map(|i| match i { + MountItem::Command { + id, + command: Command::Focus, + } => Some(*id), + _ => None, + }) + .collect() + }; + let has_keymap = |batch: &MountBatch| { + batch + .items + .iter() + .any(|i| matches!(i, MountItem::Keymap(_))) + }; + + // The group: a toolbar for screen readers, laid out by the app. + let toolbar = props(&batches, group).unwrap(); + assert_eq!(toolbar.accessibility.role, AccessibilityRole::Toolbar); + assert_eq!(toolbar.accessibility.label, None); + + // Each toggle: a button with its pressed state, its `pressedStyle` and + // the app's mark `group-aria-pressed:` while pressed. + let italic = props(&batches, b).unwrap(); + assert_eq!(italic.accessibility.role, AccessibilityRole::Button); + assert_eq!(italic.accessibility.label.as_deref(), Some("Italic")); + assert_eq!(pressed(&batches, b), Some(Checked::True)); + assert_eq!(pressed(&batches, a), Some(Checked::False)); + assert_eq!(style(&batches, b).background_color.0, 0x0000_ffff); + assert_eq!(style(&batches, a).background_color.0, 0); + let (mark_a, mark_b) = (mark(&batches, a), mark(&batches, b)); + assert_eq!(style(&batches, mark_b).background_color.0, 0x00ff_00ff); + assert_eq!(style(&batches, mark_a).background_color.0, 0); + // The mark is a part of the toggle, no second element. + assert!(props(&batches, mark_a).is_none()); + + // A press asks for the other value; the app passes it back and the + // states and styles follow, either way. + engine + .control() + .dispatch_event(a, EventKind::Press, EventPayload::None); + batches.extend(until(&rx, |batch| shows(batch, "Log: a:true"))); + assert_eq!(pressed(&batches, a), Some(Checked::True)); + assert_eq!(style(&batches, a).background_color.0, 0x0000_ffff); + assert_eq!(style(&batches, mark_a).background_color.0, 0x00ff_00ff); + engine + .control() + .dispatch_event(b, EventKind::Press, EventPayload::None); + batches.extend(until(&rx, |batch| shows(batch, "Log: a:true,b:false"))); + assert_eq!(pressed(&batches, b), Some(Checked::False)); + assert_eq!(style(&batches, mark_b).background_color.0, 0); + + // Disabled: no presses, dimmed for screen readers. + let disabled = props(&batches, c).unwrap(); + assert!(disabled.accessibility.state.disabled); + assert!( + !batches + .iter() + .flat_map(|b| b.items.iter()) + .any(|i| matches!( + i, + MountItem::Events { id, events } if *id == c && events.contains(EventKind::Press) + )) + ); + assert_eq!(style(&batches, c).opacity, 0.5); + + // One stop: the first pressed toggle (a, after the presses). + assert_eq!(stops(&batches, &[a, b, c, d]), [a]); + engine + .control() + .dispatch_event(a, EventKind::Focus, EventPayload::None); + batches.extend(until(&rx, has_keymap)); + let keys = keymap(&batches).expect("the toolbar's keys"); + for keystroke in ["left", "right", "up", "down", "home", "end"] { + assert!(bound(&keys, keystroke).is_some(), "{keystroke}"); + } + let press = |keymap: &Keymap, keystroke: &str| { + let id = bound(keymap, keystroke).unwrap_or_else(|| panic!("{keystroke} is not bound")); + engine.control().dispatch_event( + ROOT_ID, + EventKind::KeyCommand, + EventPayload::Value(PayloadValue::Number(f64::from(id))), + ); + }; + // Right: b gets the focus and becomes the stop; nothing is pressed. + press(&keys, "right"); + let after = until(&rx, |batch| { + batch.items.iter().any(|i| { + matches!( + i, + MountItem::Command { + command: Command::Focus, + .. + } + ) + }) + }); + assert_eq!(focus_calls(&after), [b]); + batches.extend(after); + assert_eq!(stops(&batches, &[a, b, c, d]), [b]); + // Right again passes the disabled toggle. + press(&keys, "right"); + let after = until(&rx, |batch| { + batch.items.iter().any(|i| { + matches!( + i, + MountItem::Command { + command: Command::Focus, + .. + } + ) + }) + }); + assert_eq!(focus_calls(&after), [d]); + batches.extend(after); + assert!( + !batches + .iter() + .any(|batch| shows(batch, "Log: a:true,b:false,")) + ); + + // A disabled group disables every toggle. + engine + .control() + .dispatch_event(lock, EventKind::Press, EventPayload::None); + batches.extend(until(&rx, |batch| { + batch.items.iter().any(|i| { + matches!( + i, + MountItem::ViewProps { id, props } if *id == a && props.accessibility.state.disabled + ) + }) + })); + for view in [a, b, d] { + assert!(props(&batches, view).unwrap().accessibility.state.disabled); + } + assert!(stops(&batches, &[a, b, c, d]).is_empty()); +} + +const BASE_COLLAPSIBLE: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Collapsible } from "nsh/base"; + +function App() { + const open = signal(false); + const count = signal(0); + return ( + + {`Details ${count()}`}} + > + open.set(!open())} + > + Summary + + + count.set(count() + 1)} /> + + ); +} + +mount(App); +"##; + +/// `nsh/base`'s `Collapsible`: its children always, its `content` after +/// them only while the app's `open` holds, made once and kept, so what is in +/// it keeps its state and follows the app while collapsed; the trigger is +/// the app's, with the expanded state it gives screen readers. +#[test] +fn a_base_collapsible_places_its_content_while_open() { + let (engine, rx) = start(BASE_COLLAPSIBLE); + let first = rx + .recv_timeout(Duration::from_secs(5)) + .expect("first frame"); + assert!(shows(&first, "Summary")); + assert!( + !first + .items + .iter() + .any(|i| matches!(i, MountItem::Text { text, .. } if text.text.starts_with("Details"))), + "collapsed: no content" + ); + let by_test_id = |test_id: &str| { + first + .items + .iter() + .find_map(|i| match i { + MountItem::ViewProps { id, props } if props.test_id.as_deref() == Some(test_id) => { + Some(*id) + } + _ => None, + }) + .expect(test_id) + }; + let (region, trigger, bump) = ( + by_test_id("region"), + by_test_id("trigger"), + by_test_id("bump"), ); let expanded = |batches: &[MountBatch]| { batches @@ -3003,40 +3657,44 @@ fn a_base_collapsible_places_its_content_while_open() { ); } -const SLOT_TRIGGERS: &str = r##"import { mount, signal, Text, View } from "nsh"; -import { Menu, MenuItem } from "nsh/base"; -import { Disclosure } from "test/ui"; +const BASE_ACCORDION: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from "nsh/base"; function App() { - const locked = signal(false); - const presses = signal(0); + const open = signal(["a"]); + const heard = signal([]); + const item = (value: string, title: string, disabled = false) => ( + + + + {title} + + + + + {`${title} details`} + + + ); return ( - - File - - } + { + heard.set([...heard(), value.join("+") || "none"]); + open.set(value); + }} > - - Open - - - presses.set(presses() + 1)} - > - Toggle - - } - /> - locked.set(!locked())} /> - {`Presses: ${presses()}`} + {item("a", "Shipping")} + {item("b", "Returns")} + {item("c", "Billing", true)} + + {`Heard: ${heard().join(",")}`} ); } @@ -3044,12 +3702,158 @@ function App() { mount(App); "##; -/// A Rust component's props on a slot a TSX app fills go to the app's -/// element in it, live: `Menu`'s trigger says whether the menu is expanded -/// as it opens and closes (its binding was read once), and a trigger the -/// component makes a button takes its presses (the app's `onPress` too), -/// its role, focus and `disabled` itself, so the app's own `active:`, -/// `focus:` and `disabled:` classes on it follow them. +/// `nsh/base`'s `Accordion` from TSX: a group of level-2 headings around +/// buttons that say whether their item is expanded (styled +/// `aria-expanded:`, `expandedStyle` and `group-aria-expanded:` while it +/// is), regions named by the app; a press reports the next `value` as one +/// object (`onChange({ value })`), one item open at a time; a disabled +/// item's trigger takes no presses. +#[test] +fn a_base_accordion_reports_its_value_and_says_what_is_expanded() { + use nsh_core::{AccessibilityRole, ViewProps}; + let (engine, rx) = start(BASE_ACCORDION); + let first = rx + .recv_timeout(Duration::from_secs(5)) + .expect("first frame"); + assert!(shows(&first, "Shipping details")); + assert!(!shows(&first, "Returns details")); + let id = |test_id: &str| { + first + .items + .iter() + .find_map(|i| match i { + MountItem::ViewProps { id, props } if props.test_id.as_deref() == Some(test_id) => { + Some(*id) + } + _ => None, + }) + .expect(test_id) + }; + let props_in = |batches: &[MountBatch], view| -> Option { + batches + .iter() + .rev() + .flat_map(|b| b.items.iter().rev()) + .find_map(|i| match i { + MountItem::ViewProps { id, props } if *id == view => Some((**props).clone()), + _ => None, + }) + }; + let style = |batches: &[MountBatch], view| { + batches + .iter() + .rev() + .flat_map(|b| b.items.iter().rev()) + .find_map(|i| match i { + MountItem::ViewStyle { id, style } if *id == view => Some(style.clone()), + _ => None, + }) + .unwrap_or_default() + }; + let initial = std::slice::from_ref(&first); + let props = |view| props_in(initial, view).expect("props"); + + // The roles: a group, headings of level 2, buttons, regions. + assert_eq!( + props(id("faq")).accessibility.role, + AccessibilityRole::Group + ); + let header = props(id("header-a")); + assert_eq!(header.accessibility.role, AccessibilityRole::Header); + assert_eq!(header.accessibility.level, Some(2)); + let (a, b, c) = (id("trigger-a"), id("trigger-b"), id("trigger-c")); + let trigger = props(a); + assert_eq!(trigger.accessibility.role, AccessibilityRole::Button); + assert_eq!(trigger.accessibility.state.expanded, Some(true)); + assert_eq!(props(b).accessibility.state.expanded, Some(false)); + let panel = props(id("panel-a")); + assert_eq!(panel.accessibility.role, AccessibilityRole::Region); + assert_eq!(panel.accessibility.label.as_deref(), Some("Shipping")); + // The open trigger's look: `aria-expanded:` and `expandedStyle`. + assert_eq!(style(initial, a).background_color.0, 0x0000_00ff); + assert_eq!(style(initial, a).opacity, 0.5); + assert_eq!(style(initial, b).background_color.0, 0xffff_ffff); + // Disabled: no presses, dimmed for screen readers. + assert!(props(c).accessibility.state.disabled); + assert!(!first.items.iter().any(|i| matches!( + i, + MountItem::Events { id, events } if *id == c && events.contains(EventKind::Press) + ))); + + // Another item: it replaces the open one. + engine + .control() + .dispatch_event(b, EventKind::Press, EventPayload::None); + let after = until(&rx, |batch| shows(batch, "Heard: b")); + assert!(after.iter().any(|batch| shows(batch, "Returns details"))); + let now = |view| props_in(&after, view).expect("props"); + assert_eq!(now(b).accessibility.state.expanded, Some(true)); + assert_eq!(now(a).accessibility.state.expanded, Some(false)); + assert_eq!(style(&after, b).background_color.0, 0x0000_00ff); + assert_eq!(style(&after, a).background_color.0, 0xffff_ffff); + + // The open item's trigger closes it: an empty value. + engine + .control() + .dispatch_event(b, EventKind::Press, EventPayload::None); + let after = until(&rx, |batch| shows(batch, "Heard: b,none")); + assert_eq!( + props_in(&after, b) + .expect("props") + .accessibility + .state + .expanded, + Some(false) + ); +} + +const SLOT_TRIGGERS: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Menu, MenuItem } from "nsh/base"; +import { Disclosure } from "test/ui"; + +function App() { + const locked = signal(false); + const presses = signal(0); + return ( + + + File + + } + > + + Open + + + presses.set(presses() + 1)} + > + Toggle + + } + /> + locked.set(!locked())} /> + {`Presses: ${presses()}`} + + ); +} + +mount(App); +"##; + +/// A Rust component's props on a slot a TSX app fills go to the app's +/// element in it, live: `Menu`'s trigger says whether the menu is expanded +/// as it opens and closes (its binding was read once), and a trigger the +/// component makes a button takes its presses (the app's `onPress` too), +/// its role, focus and `disabled` itself, so the app's own `active:`, +/// `focus:` and `disabled:` classes on it follow them. #[test] fn a_slots_props_are_the_apps_elements_own_and_live() { use nsh_core::{AccessibilityRole, ViewProps, tailwind::state}; @@ -3283,6 +4087,110 @@ fn a_popovers_trigger_is_styled_while_open() { assert_eq!(style(&closed).unwrap().background_color.0, 0xffff_ffff); } +const TOGGLE: &str = r##"import { mount, signal, Text, View } from "nsh"; + +const bold = signal(false); + +mount(() => ( + + + bold.set(!bold())} + > + {bold() ? "Bold on" : "Bold off"} + + + + Details + + + + + + +)); +"##; + +/// A toggle button's `aria-pressed:` classes and `pressedStyle` follow its +/// `accessibilityState.pressed`, which the platform gets with the new +/// roles, a heading's level and a page's current state; a level that is +/// no whole number from 1 is none. +#[test] +fn a_toggle_is_styled_while_pressed() { + use nsh_core::{AccessibilityCurrent, AccessibilityRole, Checked}; + let (engine, rx) = start(TOGGLE); + let first = rx + .recv_timeout(Duration::from_secs(5)) + .expect("first frame"); + let props = |batches: &[MountBatch], test_id: &str| { + batches + .iter() + .rev() + .flat_map(|b| b.items.iter().rev()) + .find_map(|i| match i { + MountItem::ViewProps { id, props } if props.test_id.as_deref() == Some(test_id) => { + Some((*id, props.accessibility.clone())) + } + _ => None, + }) + .expect(test_id) + }; + let first = std::slice::from_ref(&first); + let (bold, a) = props(first, "bold"); + assert_eq!(a.role, AccessibilityRole::Button); + assert_eq!(a.state.pressed, Some(Checked::False)); + let (_, heading) = props(first, "heading"); + assert_eq!( + (heading.role, heading.level), + (AccessibilityRole::Header, Some(2)) + ); + assert_eq!(props(first, "pages").1.role, AccessibilityRole::Navigation); + let (_, page) = props(first, "page"); + assert_eq!(page.state.current, Some(AccessibilityCurrent::Page)); + let (_, item) = props(first, "item"); + assert_eq!((item.role, item.level), (AccessibilityRole::TreeItem, None)); + let style = |batches: &[MountBatch]| { + batches + .iter() + .rev() + .flat_map(|b| b.items.iter().rev()) + .find_map(|i| match i { + MountItem::ViewStyle { id, style } if *id == bold => Some(style.clone()), + _ => None, + }) + }; + let off = style(first).expect("styled"); + assert_eq!(off.background_color.0, 0xffff_ffff); + assert_eq!(off.opacity, 1.0); + // The core sets the state: the platform is not asked to report it. + assert!(!first.iter().flat_map(|b| &b.items).any(|i| matches!( + i, + MountItem::TrackStates { id, states } + if *id == bold && states & nsh_core::tailwind::state::ARIA_PRESSED != 0 + ))); + let press = || { + engine + .control() + .dispatch_event(bold, EventKind::Press, EventPayload::None) + }; + press(); + let pressed = until(&rx, |b| shows(b, "Bold on")); + assert_eq!(props(&pressed, "bold").1.state.pressed, Some(Checked::True)); + let on = style(&pressed).expect("restyled"); + assert_eq!(on.background_color.0, 0x0000_00ff); + assert_eq!(on.opacity, 0.5); + press(); + let released = until(&rx, |b| shows(b, "Bold off")); + let off = style(&released).expect("restyled"); + assert_eq!(off.background_color.0, 0xffff_ffff); + assert_eq!(off.opacity, 1.0); +} + /// A popup reports a change of its open state before its lifecycle /// follows it: `onOpenChange`, then `onOpen` / `onClose` once it is all /// there or all gone, also where that is at once (no motion). @@ -3352,6 +4260,125 @@ fn popups_report_their_open_change_first_on_touch_screens() { popups_report_their_open_change_first(Environment::default()); } +const POPUP_LOOKS: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Popover } from "nsh/base"; + +function App() { + const life = signal([]); + const side = signal("none"); + return ( + + life.set([...life(), "open"])} + onClose={() => life.set([...life(), "close"])} + onPosition={({ placement, bounds, available }) => + side.set(`${placement} ${bounds.size.height} ${available.height > 0}`)} + trigger={ + + Details + + } + > + Body + + {`Life: ${life().join(" ")}`} + {`Side: ${side()}`} + + ); +} + +mount(App); +"##; + +/// A popover's states' looks and placement from TSX: made with its +/// starting class, its look from its first frame on, kept with its ending +/// style through its transition's 200 ms once closed (`onClose` after); +/// `onPosition` says the side it went on, its frame and its room. +#[test] +fn a_popovers_looks_follow_its_presence_from_tsx() { + let (engine, rx) = start_in( + POPUP_LOOKS, + Environment { + os: nsh_engine::Os::Windows, + ..Environment::default() + }, + ); + let first = rx + .recv_timeout(Duration::from_secs(5)) + .expect("first frame"); + let trigger = first + .items + .iter() + .find_map(|i| match i { + MountItem::ViewProps { id, props } if props.test_id.as_deref() == Some("trigger") => { + Some(*id) + } + _ => None, + }) + .expect("the trigger"); + let press = || { + engine + .control() + .dispatch_event(trigger, EventKind::Press, EventPayload::None) + }; + // The popup is the view the body's text is inserted into. + let popup_of = |batches: &[MountBatch]| { + batches.iter().find_map(|b| { + let body = text_ids(b, "Body")?; + b.items.iter().find_map(|i| match i { + MountItem::Insert { parent, child, .. } if *child == body => Some(*parent), + _ => None, + }) + }) + }; + let opacities = |batches: &[MountBatch], popup: u32| -> Vec { + batches + .iter() + .flat_map(|b| b.items.iter()) + .filter_map(|i| match i { + MountItem::ViewStyle { id, style } if *id == popup => Some(style.opacity), + _ => None, + }) + .collect() + }; + press(); + let mut opened = until(&rx, |b| shows(b, "Side: bottom 40 true")); + let popup = popup_of(&opened).expect("the popup"); + assert_eq!( + opacities(&opened, popup).first(), + Some(&0.0), + "made with its starting class" + ); + if opacities(&opened, popup).last() != Some(&1.0) { + opened = until(&rx, |b| { + opacities(std::slice::from_ref(b), popup).last() == Some(&1.0) + }); + assert_eq!( + opacities(&opened, popup).last(), + Some(&1.0), + "its look after its first frame" + ); + } + press(); + let closing = until(&rx, |b| { + opacities(std::slice::from_ref(b), popup).last() == Some(&0.5) + }); + assert_eq!( + opacities(&closing, popup).last(), + Some(&0.5), + "kept with its ending style" + ); + assert!(!closing.iter().any(|b| shows(b, "Life: open close"))); + let closed = until(&rx, |b| shows(b, "Life: open close")); + assert!( + closed.iter().any(|b| shows(b, "Life: open close")), + "closed after its exit" + ); +} + const BASE_PROGRESS: &str = r##"import { mount, signal, Text, View } from "nsh"; import { Progress } from "nsh/base"; @@ -3902,33 +4929,227 @@ fn a_base_combobox_toggles_the_apps_array_and_confirms_it_on_close() { until(&rx, |b| shows(b, "Confirmed: go,swift")); } -const BASE_SELECT_DESKTOP: &str = r##"import { mount, Text, View } from "nsh"; -import { Select } from "nsh/base"; +const BASE_DATE_PICKER: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { DatePicker } from "nsh/base"; -const items = [ - { value: "apple", title: "Apple" }, - { value: "rice", title: "Rice" }, -]; +function App() { + const value = signal("2026-10-10"); + const range = signal<[string | null, string | null] | null>(null); + return ( + + value.set(next as string | null)} + disabledMatcher={(date) => date.endsWith("-14")} + accessibilityLabel="Due" + trigger={Pick} + renderItem={(item) => {item.kind === "day" ? `${item.date}${item.active ? "*" : ""}` : `${item.kind}`}} + /> + range.set(next as [string | null, string | null])} + presets={[{ label: "Week", value: ["2026-10-19", "2026-10-25"] }]} + accessibilityLabel="Trip" + trigger={Trip} + /> + {`Value: ${value()}`} + {`Range: ${JSON.stringify(range())}`} + + ); +} -mount(() => ( - - + Pick + + } + /> + +)); +"##; + +/// On a desktop, a TSX `Select`'s trigger is the app's element: the +/// combobox, and what the popup is placed against and as wide as (its +/// slot's view lays it out through, taking its box); open at first (`defaultOpen`), /// the popup shows its rows once the trigger is laid out. #[test] fn a_base_select_on_a_desktop_places_its_popup_at_the_apps_trigger() { @@ -5097,11 +6318,12 @@ fn base_plot_moves_natively_with_its_motion() { MountItem::ViewProps { id, props } if *id == plot && props.accessibility.value.as_ref().and_then(|v| v.text.as_deref()) == Some("row 1") ))); - // Over the plot: the next row on the pointer spring. + // Over the plot: the next row under the pointer in the same frame, with + // no spring (the pointer spring is the keys'), and no mark drawn again. hover(190.0); - let glided = until(&rx, |b| shows(b, "hover 2")); - assert!(springs(&glided) > 0); - assert!(plot_drawings(&glided).is_empty()); + let moved = until(&rx, |b| shows(b, "hover 2")); + assert_eq!(springs(&moved), 0); + assert!(plot_drawings(&moved).is_empty()); // Leaving: faded out over 80 ms. engine.control().dispatch_event( plot, @@ -5784,18 +7006,660 @@ fn base_plot_pie_radii_move_with_the_update_motion() { assert!(edges.iter().all(|e| e.1.is_some()), "{edges:?}"); } -const BASE_PLOT_ARC_CORNERS: &str = r##"import { mount, Text, View } from "nsh"; -import { Arc, Plot } from "nsh/base/plot"; +const BASE_PLOT_ARC_CORNERS: &str = r##"import { mount, Text, View } from "nsh"; +import { Arc, Plot } from "nsh/base/plot"; + +function App() { + return ( + + + + + + + ready + + ); +} + +mount(App); +"##; + +/// `cornerRadius` (d3's `arc.cornerRadius`): an arc's corners are circles +/// touching its edges and the rim or the hole, at most half the ring's +/// thickness (round caps: the corners at the rim and at the hole are one +/// circle) and, on a short arc, only as large as its angle leaves room +/// for, the two corners meeting on its middle. A whole ring has none. +#[test] +fn base_plot_arc_corners_are_rounded_and_clamped() { + use std::f32::consts::{FRAC_PI_2, PI}; + let (_engine, rx) = start(BASE_PLOT_ARC_CORNERS); + let mut all = until(&rx, |b| shows(b, "ready")); + all.extend(until(&rx, |_| false)); + let drawings = plot_drawings(&all); + let arcs: Vec> = drawings.iter().map(|(_, d)| drawn_arcs(&d.ops)).collect(); + let near = |a: f32, b: f32| (a - b).abs() < 1e-3; + let at = |arc: &DrawnArc, x: f32, y: f32| near(arc.0.0, x) && near(arc.0.1, y); + // The quarter from 12 o'clock: corners of 10 (half of 50 - 30, not + // 20), their centers on the ring's middle, 40 out. + let quarter = arcs + .iter() + .find(|a| a.len() == 6 && a[1].1 == Some(50.0) && !near(a[1].2.0, a[1].2.1)) + .unwrap_or_else(|| panic!("a rounded quarter: {arcs:?}")); + let a = (10.0f32 / 40.0).asin(); + assert!( + at(&quarter[0], 50.0 + 40.0 * a.sin(), 50.0 - 40.0 * a.cos()), + "{quarter:?}" + ); + assert_eq!(quarter[0].1, Some(10.0)); + assert!(near(quarter[0].2.0, -PI) && near(quarter[0].2.1, -FRAC_PI_2 + a)); + // The rim between the corners, then the hole's edge back. + assert!(near(quarter[1].2.0, a - FRAC_PI_2) && near(quarter[1].2.1, -a)); + assert_eq!((quarter[4].1, quarter[4].3), (Some(30.0), true)); + assert!(near(quarter[4].2.0, -a) && near(quarter[4].2.1, a - FRAC_PI_2)); + // Round caps: the corners at the rim and at the hole are one circle. + assert!(at(&quarter[3], quarter[2].0.0, quarter[2].0.1)); + assert!(at(&quarter[5], quarter[0].0.0, quarter[0].0.1)); + // A short arc (0.2): the corners only as large as its angle leaves + // room for, meeting on its middle, the rim between them none. + let half = 0.1f32.sin(); + let short = arcs + .iter() + .find(|a| a.len() == 6 && a[1].1 == Some(50.0) && near(a[1].2.0, a[1].2.1)) + .unwrap_or_else(|| panic!("a short rounded arc: {arcs:?}")); + assert!( + near(short[0].1.unwrap(), 50.0 * half / (1.0 + half)), + "{short:?}" + ); + assert!( + near(short[3].1.unwrap(), 30.0 * half / (1.0 - half)), + "{short:?}" + ); + // The whole ring: no corners, its two edges only. + assert!( + arcs.iter().any(|a| a.len() == 2 && a[0].1 == Some(50.0)), + "{arcs:?}" + ); +} + +const BASE_PLOT_SANKEY: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Plot, Sankey } from "nsh/base/plot"; + +type Stage = { name: string }; +const FILLS = ["fill-[#ff0000]", "fill-[#00ff00]", "fill-[#0000ff]"]; + +function App() { + const hovered = signal(null); + const two: Stage[] = [{ name: "a" }, { name: "b" }]; + return ( + + {/* A fan-out of 36 and 12 from one node, the layout's first + placement (no relaxation). */} + hovered.set(index)} + style={{ width: 200, height: 104 }} + > + FILLS[i]} + linkClass="fill-[#999999]" + /> + + {/* A cycle, a row the plot does not have, an index that is none: + nothing drawn. */} + + + + + + + + + + {/* Appearing (for long): its nodes and ribbon clipped from the + leading edge. */} + + + + {`hover ${hovered()}`} + + ); +} + +mount(App); +"##; + +/// `nsh/base/plot`'s `Sankey` (`gpui-base`'s sankey layout, +/// `gpui-component`'s `SankeyChart` painting): the plot's rows are its +/// nodes, a canvas each, placed in columns as d3-sankey places them, and a +/// ribbon per link from its source's right edge to its target's left edge, +/// as wide at each end as its share of that side, its curves' control +/// points at the middle. Each node takes its row's classes, each ribbon +/// the links'. Links that make a cycle or name no row draw nothing; with +/// the plot's appear everything clips from the leading edge, and a +/// background shows inside the ribbon. +#[test] +fn base_plot_sankey_layout() { + use nsh_core::{ + Color, + canvas::{CanvasOp, GradientShape, Num, Paint, PathOp}, + geometry::point, + }; + let (_engine, rx) = start(BASE_PLOT_SANKEY); + // The nodes' classes come a turn after the rows. + let mut all = until_drawn(&rx, |d| { + d.ops + .contains(&CanvasOp::FillStyle(Paint::Color(Color(0x0000_ffff)))) + }); + all.extend(until(&rx, |_| false)); + let clipped = + |d: &nsh_core::canvas::Drawing| d.ops.iter().any(|op| matches!(op, CanvasOp::Clip(..))); + let raw = plot_canvases(&all); + let drawings = plot_drawings(&all); + // Only the first plot and the appearing one draw: three nodes and two + // ribbons, two nodes, a ribbon and its background. + let (appearing, fan): (Vec<_>, Vec<_>) = drawings.iter().partition(|(_, d)| clipped(d)); + assert_eq!((fan.len(), appearing.len()), (5, 4), "{drawings:?}"); + let rect = |d: &nsh_core::canvas::Drawing| { + d.ops.iter().find_map(|op| match op { + CanvasOp::Path(PathOp::RoundRect(r, corners)) => Some((*r, *corners)), + _ => None, + }) + }; + let fill = |d: &nsh_core::canvas::Drawing| { + d.ops.iter().find_map(|op| match op { + CanvasOp::FillStyle(Paint::Color(c)) => Some(*c), + _ => None, + }) + }; + let c = Num::Const; + // The nodes: 10 wide, a column at each end (190 = 200 - 10); the + // source 96 tall (48 at 2 a unit: the crowded column's 104 less one + // gap of 8, over 48), centered; its targets 72 and 24 tall, 8 apart. + // Corners of 2; each its row's fill. + let mut nodes: Vec<_> = fan + .iter() + .filter_map(|(id, d)| Some((*id, rect(d)?, fill(d)))) + .collect(); + let at = |n: &Num| match n { + Num::Const(v) => *v, + _ => f32::NAN, + }; + nodes.sort_by(|a, b| { + (at(&a.1.0[0]), at(&a.1.0[1])) + .partial_cmp(&(at(&b.1.0[0]), at(&b.1.0[1]))) + .unwrap() + }); + let shapes: Vec<_> = nodes.iter().map(|(_, (r, _), f)| (*r, *f)).collect(); + assert_eq!( + shapes, + [ + ([c(0.0), c(4.0), c(10.0), c(96.0)], Some(Color(0xff00_00ff))), + ( + [c(190.0), c(0.0), c(10.0), c(72.0)], + Some(Color(0x00ff_00ff)) + ), + ( + [c(190.0), c(80.0), c(10.0), c(24.0)], + Some(Color(0x0000_ffff)) + ), + ] + ); + assert_eq!(nodes[0].1.1, [c(2.0); 4]); + // The ribbons: from the source's right edge to each target's left + // edge, 72 and 24 wide at both ends, stacked in the source in its + // targets' order; the links' fill. + let mut ribbons: Vec<_> = fan + .iter() + .filter(|(_, d)| rect(d).is_none()) + .map(|(id, d)| (*id, path_points(&d.ops), fill(d))) + .collect(); + ribbons.sort_by(|a, b| a.1[0].1.total_cmp(&b.1[0].1)); + assert_eq!( + ribbons.iter().map(|r| (&r.1[..], r.2)).collect::>(), + [ + ( + &[(10.0, 4.0), (190.0, 0.0), (190.0, 72.0), (10.0, 76.0)][..], + Some(Color(0x9999_99ff)) + ), + ( + &[(10.0, 76.0), (190.0, 80.0), (190.0, 104.0), (10.0, 100.0)][..], + Some(Color(0x9999_99ff)) + ), + ] + ); + // `sankeyLinkHorizontal`: the control points at the horizontal middle, + // level with each end. + let (_, first) = fan.iter().find(|(id, _)| *id == ribbons[0].0).unwrap(); + let cubic = first.ops.iter().find_map(|op| match op { + CanvasOp::Path(PathOp::CubicTo(n)) => Some(*n), + _ => None, + }); + assert_eq!( + cubic, + Some([c(100.0), c(4.0), c(100.0), c(0.0), c(190.0), c(0.0)]) + ); + // A ribbon reaches past the plot by the strokes' room and half its + // least width (1), a node by the strokes' room. + let bleed = |id: u32| { + raw.iter() + .find(|(i, _)| *i == id) + .and_then(|(_, d)| match d.ops.first() { + Some(CanvasOp::Translate(Num::Const(x), _)) => Some(*x), + _ => None, + }) + }; + assert_eq!(bleed(ribbons[0].0), Some(9.0)); + assert_eq!(bleed(nodes[0].0), Some(8.0)); + // Appearing: its ribbon's background part fills the ribbon with the + // app's gradient across its box, from the source's right edge (24, the + // default node width) to the target's left edge. + let gradient = appearing + .iter() + .find_map(|(_, d)| { + d.ops.iter().find_map(|op| match op { + CanvasOp::FillStyle(Paint::Gradient(g)) => Some(g.clone()), + _ => None, + }) + }) + .unwrap_or_else(|| panic!("a gradient: {appearing:?}")); + assert_eq!( + gradient.shape, + GradientShape::Linear { + start: point(px(24.0), px(50.0)), + end: point(px(176.0), px(50.0)), + } + ); + assert_eq!(gradient.stops[0].1, Color(0xff00_00ff)); +} + +const BASE_PLOT_SANKEY_HOVER: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Plot, Sankey } from "nsh/base/plot"; + +type Stage = { name: string }; + +function App() { + const hovered = signal(null); + return ( + + hovered.set(index)} + style={{ width: 200, height: 104 }} + > + d.name} + valueLabel={(_: Stage, value: number) => `v${value}`} + nodeLabelStyle={{ width: 30, height: 40 }} + /> + + {`hover ${hovered()}`} + + ); +} + +mount(App); +"##; + +/// A `Sankey`'s node hover, keys and labels (`gpui-component`'s +/// `SankeyChart`): the pointer over a node reports its row, and the links +/// not attached to it fade by `hoverDim` on the animator, none drawn +/// again; off the nodes, none. The keys step through the nodes. Each +/// node's label (its value over its name) is a point with no size beside +/// it: left of the first column's nodes, right of the last's, at their +/// middles, kept inside the plot's top and bottom. +#[test] +fn base_plot_sankey_hovers_its_nodes_and_labels_them() { + use nsh_core::{ + animation::Animator, + canvas::{CanvasOp, PathOp}, + geometry::{point, size}, + keymap::{KeyMatch, Keymap, Keystroke}, + platform::Os, + }; + let environment = Environment { + os: Os::MacOs, + ..Environment::default() + }; + let (engine, rx) = start_in(BASE_PLOT_SANKEY_HOVER, environment); + // The labels' texts come a turn after the rows and the links. + let mut all = until(&rx, |b| shows(b, "v12")); + all.extend(until(&rx, |_| false)); + let id_of = |text: &str| { + all.iter() + .find_map(|b| text_ids(b, text)) + .unwrap_or_else(|| panic!("{text}")) + }; + let parent = |all: &[MountBatch], id: u32| { + all.iter() + .flat_map(|b| b.items.iter()) + .find_map(|i| match i { + MountItem::Insert { parent, child, .. } if *child == id => Some(*parent), + _ => None, + }) + .expect("a parent") + }; + let frame = |id: u32| frame_of(&all, id).expect("laid out"); + let bounds = |x: f32, y: f32, w: f32, h: f32| { + nsh_core::Bounds::new(point(px(x), px(y)), size(px(w), px(h))) + }; + // Each label's value over its name, in a box of the app's 30 by 40: + // the first column's left of its node (0) at its middle (52); the + // last's right of theirs (200), at 36, and at 84 rather than 92, the + // box kept inside the plot's bottom (104). + for (name, value, at, content) in [ + ("a", "v48", (0.0, 52.0), (-30.0, -20.0)), + ("b", "v36", (200.0, 36.0), (0.0, -20.0)), + ("c", "v12", (200.0, 84.0), (0.0, -20.0)), + ] { + let lines = parent(&all, id_of(name)); + assert_eq!(parent(&all, id_of(value)), lines); + let anchor = parent(&all, lines); + assert_eq!(frame(anchor), bounds(at.0, at.1, 0.0, 0.0), "{name}"); + assert_eq!( + frame(lines), + bounds(content.0, content.1, 30.0, 40.0), + "{name}" + ); + } + + let plot = test_view(&all, "plot"); + let hover = |x: f32, y: f32| { + engine.control().dispatch_event( + plot, + EventKind::MouseMove, + EventPayload::Mouse(nsh_core::MouseEvent::new( + point(px(x), px(y)), + point(px(x), px(y)), + Default::default(), + None, + )), + ) + }; + // The ribbons, by where they start: to `b`, then to `c`. + let mut ribbons: Vec<_> = plot_drawings(&all) + .into_iter() + .filter(|(_, d)| { + d.ops + .iter() + .any(|op| matches!(op, CanvasOp::Path(PathOp::CubicTo(_)))) + }) + .map(|(id, d)| (id, path_points(&d.ops)[0].1)) + .collect(); + ribbons.sort_by(|a, b| a.1.total_cmp(&b.1)); + let [(to_b, _), (to_c, _)] = ribbons[..] else { + panic!("two ribbons: {ribbons:?}") + }; + let opacity = |all: &[MountBatch], view: u32| { + let mut animator = Animator::default(); + for item in all.iter().flat_map(|b| b.items.iter()) { + animator.apply(item); + } + let mut out = f32::NAN; + animator.write( + |id| (id == view).then_some(id), + |_, values, _| out = values.opacity.unwrap_or(f32::NAN), + ); + out + }; + let near = |a: f32, b: f32| (a - b).abs() < 1e-5; + assert!(near(opacity(&all, to_c), 1.0)); + // Over `a`: both its links stay. + hover(5.0, 50.0); + let over = until(&rx, |b| shows(b, "hover 0")); + assert!(plot_drawings(&over).is_empty(), "nothing drawn again"); + all.extend(over); + all.extend(until(&rx, |_| false)); + assert!(near(opacity(&all, to_b), 1.0) && near(opacity(&all, to_c), 1.0)); + // Over `b`: the link to `c` fades by half. + hover(195.0, 30.0); + all.extend(until(&rx, |b| shows(b, "hover 1"))); + all.extend(until(&rx, |_| false)); + assert!(near(opacity(&all, to_b), 1.0), "{}", opacity(&all, to_b)); + assert!(near(opacity(&all, to_c), 0.5), "{}", opacity(&all, to_c)); + // Over a ribbon, between the nodes: none. + hover(100.0, 50.0); + all.extend(until(&rx, |b| shows(b, "hover null"))); + engine.control().dispatch_event( + plot, + EventKind::MouseLeave, + EventPayload::Mouse(nsh_core::MouseEvent::default()), + ); + all.extend(until(&rx, |_| false)); + + // The keys step through the nodes in the rows' order. + let keymap = |all: &[MountBatch]| -> Option> { + all.iter() + .flat_map(|b| b.items.iter()) + .filter_map(|i| match i { + MountItem::Keymap(k) => Some(k.clone()), + _ => None, + }) + .next_back() + }; + let press = |all: &[MountBatch], key: &str| { + let found = keymap(all).map_or(KeyMatch::None, |k| { + k.find(Keystroke::parse(key, Os::MacOs).unwrap(), false, false) + }); + let KeyMatch::Fire(id) = found else { + panic!("{key} is not bound") + }; + engine.control().dispatch_event( + ROOT_ID, + EventKind::KeyCommand, + EventPayload::Value(PayloadValue::Number(f64::from(id))), + ); + }; + engine + .control() + .dispatch_event(plot, EventKind::Focus, EventPayload::None); + all.extend(until(&rx, |b| { + b.items.iter().any(|i| matches!(i, MountItem::Keymap(_))) + })); + press(&all, "right"); + all.extend(until(&rx, |b| shows(b, "hover 0"))); + press(&all, "right"); + all.extend(until(&rx, |b| shows(b, "hover 1"))); + press(&all, "end"); + all.extend(until(&rx, |b| shows(b, "hover 2"))); + all.extend(until(&rx, |_| false)); + press(&all, "escape"); + all.extend(until(&rx, |b| shows(b, "hover null"))); +} + +const BASE_PLOT_SANKEY_UPDATE: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Plot, Sankey } from "nsh/base/plot"; + +function App() { + const late = signal(false); + setTimeout(() => late.set(true), 50); + const motion = { update: { duration: 200, easing: "linear" as const } }; + return ( + + {/* The same two links, reordered, 12 and 36 rather than 36 and 12. */} + + + + {/* A node and a link new to it. */} + + + + {late() ? "late" : "early"} + + ); +} + +mount(App); +"##; + +/// A `Sankey` moving to new flows (`motion.update`, d3's transitions of a +/// sankey's attributes): each node and ribbon keeps its canvas (links are +/// told apart by source and target, so a reordered one is the same +/// ribbon) and every number of it moves from the old layout to the new +/// on the animator, half-way at half the time; a node new to it grows +/// from its middle, a link new to it from its least width. With reduced +/// motion the new layout shows at once. +#[test] +fn base_plot_sankey_moves_to_new_flows() { + use nsh_core::{ + animation::{AnimationOp, Driver}, + canvas::{CanvasOp, Num, PathOp}, + }; + let updates = |b: &MountBatch| { + b.items.iter().any(|i| { + matches!( + i, + MountItem::Animation(AnimationOp::Start { + driver: Driver::Timing { duration_ms, .. }, + .. + }) if *duration_ms == 200.0 + ) + }) + }; + let (_engine, rx) = start(BASE_PLOT_SANKEY_UPDATE); + let mut all = until(&rx, |b| shows(b, "late")); + all.extend(until(&rx, |_| false)); + assert!(all.iter().any(updates), "the flows move over the update"); + let switched = all.iter().position(|b| shows(b, "late")).unwrap(); + let before = plot_drawings(&all[..switched]); + let after = plot_drawings(&all[switched..]); + // Only the second plot's new node and link are new canvases. + let new: Vec<_> = after + .iter() + .filter(|(id, _)| !before.iter().any(|(b, _)| b == id)) + .collect(); + assert_eq!(new.len(), 2, "{new:?}"); + let latest = plot_drawings(&all); + let drawing = |id: u32| &latest.iter().find(|(i, _)| *i == id).unwrap().1; + // A number of a drawing at the update's start, half-way and end. + let at = |id: u32, n: Num| match n { + Num::Slot(slot) => slot_at(&all, id, slot, &[1.0, 1.1, 1.2]), + Num::Const(c) => vec![c; 3], + }; + let near = |got: Vec, want: [f32; 3]| { + assert!( + got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), + "{got:?} is not {want:?}" + ) + }; + let rect_of = |d: &nsh_core::canvas::Drawing| { + d.ops.iter().find_map(|op| match op { + CanvasOp::Path(PathOp::Rect(r)) => Some(*r), + _ => None, + }) + }; + let rect = |id: u32| rect_of(drawing(id)); + let ends = |id: u32| { + let ops = &drawing(id).ops; + let line = ops.iter().find_map(|op| match op { + CanvasOp::Path(PathOp::LineTo(_, y)) => Some(*y), + _ => None, + }); + let last = ops.iter().rev().find_map(|op| match op { + CanvasOp::Path(PathOp::CubicTo(n)) => Some(n[5]), + _ => None, + }); + (line.unwrap(), last.unwrap()) + }; + // The first plot's ribbon to `b`, by where it was: its bottom edge + // from 72 to 24 at the target, from 76 to 28 at the source. + let to_b = before + .iter() + .find(|(_, d)| { + path_points(&d.ops) == [(10.0, 4.0), (190.0, 0.0), (190.0, 72.0), (10.0, 76.0)] + }) + .map(|(id, _)| *id) + .expect("the ribbon to b"); + let (t_bottom, s_bottom) = ends(to_b); + near(at(to_b, t_bottom), [72.0, 48.0, 24.0]); + near(at(to_b, s_bottom), [76.0, 52.0, 28.0]); + // Its node `c`: from 80, 24 tall to 32, 72 tall. + // (Where it was before the change: its drawing then.) + let c = before + .iter() + .find(|(_, d)| rect_of(d).is_some_and(|r| r[1] == Num::Const(80.0))) + .map(|(id, _)| *id) + .expect("node c"); + let r = rect(c).unwrap(); + near(at(c, r[1]), [80.0, 56.0, 32.0]); + near(at(c, r[3]), [24.0, 48.0, 72.0]); + // The second plot's new node grows from its middle (77) to 54..100; + // its new ribbon from a point thick (72.5) to 50 at its source's top. + let node = new.iter().find(|(id, _)| rect(*id).is_some()).unwrap().0; + let ribbon = new.iter().find(|(id, _)| rect(*id).is_none()).unwrap().0; + let r = rect(node).unwrap(); + near(at(node, r[1]), [77.0, 65.5, 54.0]); + near(at(node, r[3]), [0.0, 23.0, 46.0]); + let start = drawing(ribbon).ops.iter().find_map(|op| match op { + CanvasOp::Path(PathOp::MoveTo(_, y)) => Some(*y), + _ => None, + }); + near(at(ribbon, start.unwrap()), [72.5, 61.25, 50.0]); + + // Reduced motion: the new flows at once. + let environment = Environment { + reduce_motion: true, + ..Environment::default() + }; + let (_engine, rx) = start_in(BASE_PLOT_SANKEY_UPDATE, environment); + let mut all = until(&rx, |b| shows(b, "late")); + all.extend(until(&rx, |_| false)); + assert!(!all.iter().any(updates)); +} + +const BASE_PLOT_SANKEY_RETARGET: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Plot, Sankey } from "nsh/base/plot"; function App() { + const step = signal(0); + setTimeout(() => step.set(1), 50); + setTimeout(() => step.set(2), 150); + const flows = [ + [36, 12], + [12, 36], + [30, 18], + ]; return ( - - - - + + - ready + {`step ${step()}`} ); } @@ -5803,61 +7667,71 @@ function App() { mount(App); "##; -/// `cornerRadius` (d3's `arc.cornerRadius`): an arc's corners are circles -/// touching its edges and the rim or the hole, at most half the ring's -/// thickness (round caps: the corners at the rim and at the hole are one -/// circle) and, on a short arc, only as large as its angle leaves room -/// for, the two corners meeting on its middle. A whole ring has none. +/// New flows while a `Sankey` moves (`motion.update`): the move stops, the +/// animator is asked how far it had gone, and the next move starts from +/// the layout drawn there, not from the first move's target, as a pie's +/// does. #[test] -fn base_plot_arc_corners_are_rounded_and_clamped() { - use std::f32::consts::{FRAC_PI_2, PI}; - let (_engine, rx) = start(BASE_PLOT_ARC_CORNERS); - let mut all = until(&rx, |b| shows(b, "ready")); - all.extend(until(&rx, |_| false)); - let drawings = plot_drawings(&all); - let arcs: Vec> = drawings.iter().map(|(_, d)| drawn_arcs(&d.ops)).collect(); +fn base_plot_sankey_moves_on_from_where_it_is_drawn() { + use nsh_core::{ + animation::{AnimationOp, Finished}, + canvas::{CanvasOp, Num, PathOp}, + }; let near = |a: f32, b: f32| (a - b).abs() < 1e-3; - let at = |arc: &DrawnArc, x: f32, y: f32| near(arc.0.0, x) && near(arc.0.1, y); - // The quarter from 12 o'clock: corners of 10 (half of 50 - 30, not - // 20), their centers on the ring's middle, 40 out. - let quarter = arcs - .iter() - .find(|a| a.len() == 6 && a[1].1 == Some(50.0) && !near(a[1].2.0, a[1].2.1)) - .unwrap_or_else(|| panic!("a rounded quarter: {arcs:?}")); - let a = (10.0f32 / 40.0).asin(); - assert!( - at(&quarter[0], 50.0 + 40.0 * a.sin(), 50.0 - 40.0 * a.cos()), - "{quarter:?}" - ); - assert_eq!(quarter[0].1, Some(10.0)); - assert!(near(quarter[0].2.0, -PI) && near(quarter[0].2.1, -FRAC_PI_2 + a)); - // The rim between the corners, then the hole's edge back. - assert!(near(quarter[1].2.0, a - FRAC_PI_2) && near(quarter[1].2.1, -a)); - assert_eq!((quarter[4].1, quarter[4].3), (Some(30.0), true)); - assert!(near(quarter[4].2.0, -a) && near(quarter[4].2.1, a - FRAC_PI_2)); - // Round caps: the corners at the rim and at the hole are one circle. - assert!(at(&quarter[3], quarter[2].0.0, quarter[2].0.1)); - assert!(at(&quarter[5], quarter[0].0.0, quarter[0].0.1)); - // A short arc (0.2): the corners only as large as its angle leaves - // room for, meeting on its middle, the rim between them none. - let half = 0.1f32.sin(); - let short = arcs - .iter() - .find(|a| a.len() == 6 && a[1].1 == Some(50.0) && near(a[1].2.0, a[1].2.1)) - .unwrap_or_else(|| panic!("a short rounded arc: {arcs:?}")); - assert!( - near(short[0].1.unwrap(), 50.0 * half / (1.0 + half)), - "{short:?}" - ); - assert!( - near(short[3].1.unwrap(), 30.0 * half / (1.0 - half)), - "{short:?}" - ); - // The whole ring: no corners, its two edges only. - assert!( - arcs.iter().any(|a| a.len() == 2 && a[0].1 == Some(50.0)), - "{arcs:?}" + let reads = |all: &[MountBatch]| -> Vec { + all.iter() + .flat_map(|b| b.items.iter()) + .filter_map(|i| match i { + MountItem::Animation(AnimationOp::Read { request, .. }) => Some(*request), + _ => None, + }) + .collect() + }; + let (engine, rx) = start(BASE_PLOT_SANKEY_RETARGET); + let mut all = until(&rx, |b| shows(b, "step 2")); + if reads(&all).is_empty() { + all.extend(until(&rx, |b| { + b.items + .iter() + .any(|i| matches!(i, MountItem::Animation(AnimationOp::Read { .. }))) + })); + } + // One move, and it asks where it is. + let asked = reads(&all); + assert_eq!(asked.len(), 1, "{asked:?}"); + // Halfway, as the platform answers: node `c` drawn from 80, 24 tall + // halfway to 32, 72 tall, so at 56, 48 tall. + engine.control().animations_finished( + asked + .iter() + .map(|&request| Finished { + animation: request, + finished: true, + value: Some(0.5), + }) + .collect(), ); + let answered = all.len(); + all.extend(until(&rx, |_| false)); + // From there, not from the first move's target (32, 72 tall), to the + // last flows' 68, 36 tall. + let c = plot_drawings(&all[answered..]) + .into_iter() + .find_map(|(id, d)| { + d.ops.iter().find_map(|op| match op { + CanvasOp::Path(PathOp::Rect([_, Num::Slot(y), _, Num::Slot(h)])) => { + Some((id, *y, *h)) + } + _ => None, + }) + }); + let Some((c, y, h)) = c else { + panic!("node c, moving: {:?}", plot_drawings(&all[answered..])) + }; + let ys = slot_at(&all, c, y, &[1.0, 3.0]); + assert!(near(ys[0], 56.0) && near(ys[1], 68.0), "{ys:?}"); + let hs = slot_at(&all, c, h, &[1.0, 3.0]); + assert!(near(hs[0], 48.0) && near(hs[1], 36.0), "{hs:?}"); } const BASE_PLOT_ARC_TWEEN: &str = r##"import { mount, signal, Text, View } from "nsh"; @@ -5996,22 +7870,318 @@ fn base_plot_arc_and_pie_angles_move_with_the_update_motion() { let (_engine, rx) = start_in(BASE_PLOT_ARC_TWEEN, environment); let mut all = until(&rx, |b| shows(b, "late")); all.extend(until(&rx, |_| false)); - assert!(!all.iter().any(updates)); - let arc = rim_ends(&all, 50.0); - assert!( - matches!(arc[..], [(_, Num::Const(end))] if near(end, FRAC_PI_2 - a)), - "{arc:?}" - ); - let slices = rim_ends(&all, 20.0); - assert!( - matches!(slices[0], (_, Num::Const(end)) if near(end, FRAC_PI_2)), - "{slices:?}" - ); - // The second slice ends at 12 o'clock again. - assert!( - matches!(slices[1], (_, Num::Const(end)) if near(end, 3.0 * FRAC_PI_2)), - "{slices:?}" - ); + assert!(!all.iter().any(updates)); + let arc = rim_ends(&all, 50.0); + assert!( + matches!(arc[..], [(_, Num::Const(end))] if near(end, FRAC_PI_2 - a)), + "{arc:?}" + ); + let slices = rim_ends(&all, 20.0); + assert!( + matches!(slices[0], (_, Num::Const(end)) if near(end, FRAC_PI_2)), + "{slices:?}" + ); + // The second slice ends at 12 o'clock again. + assert!( + matches!(slices[1], (_, Num::Const(end)) if near(end, 3.0 * FRAC_PI_2)), + "{slices:?}" + ); +} + +const BASE_PLOT_RADAR: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Plot, RadialGrid, RadialLine } from "nsh/base/plot"; + +type Row = { a: number; b: number }; + +function App() { + const hovered = signal(null); + const more = signal(false); + const rows = (): Row[] => [ + { a: 10, b: 5 }, + { a: more() ? 10 : 5, b: 10 }, + { a: 10, b: 5 }, + { a: 0, b: 10 }, + ]; + return ( + + hovered.set(index)} + style={{ width: 200, height: 100 }} + > + + + + + {`hover ${hovered()}`} + more.set(true)} style={{ height: 20 }} /> + + ); +} + +mount(App); +"##; + +/// The ops of a radial line's drawing from its stroke on: its line, after +/// its area. +fn line_ops(ops: &[nsh_core::canvas::CanvasOp]) -> &[nsh_core::canvas::CanvasOp] { + use nsh_core::canvas::CanvasOp; + let at = ops + .iter() + .position(|op| matches!(op, CanvasOp::StrokeStyle(_))) + .expect("a stroke"); + &ops[at..] +} + +/// `nsh/base/plot`'s `RadialLine` and `RadialGrid` (`gpui-base`'s +/// `RadialLine`, d3's `lineRadial` / `areaRadial`), a radar: the rows +/// evenly round the turn from 12 o'clock, each as far out as the y scale +/// puts its value between the radii (the center to the largest circle +/// the plot holds by default); `closed` back to the first point, its area +/// filled; a smooth open line from an inner radius; the grid's polygon +/// rings at values of the y scale and a spoke per row. The pointer finds +/// the row nearest by angle within a line's outer radius, the line's +/// hover dot on that row's point, and new values move the points over +/// `motion.update`. +#[test] +fn base_plot_radial_line_draws_a_radar_and_follows_the_pointer_by_angle() { + use nsh_core::{ + Color, + animation::Animator, + canvas::{CanvasOp, Num, Paint, PathOp}, + geometry::point, + }; + let near = |got: &[(f32, f32)], want: &[(f32, f32)]| { + assert!( + got.len() == want.len() + && got + .iter() + .zip(want) + .all(|(a, b)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3), + "{got:?} is not {want:?}" + ) + }; + let (engine, rx) = start(BASE_PLOT_RADAR); + let mut all = until_drawn(&rx, |d| { + d.ops + .iter() + .filter(|op| matches!(op, CanvasOp::Path(PathOp::CubicTo(_)))) + .count() + >= 3 + }); + all.extend(until(&rx, |_| false)); + assert_eq!(plot_bleeds(&all), [8.0; 3]); + // The marks by their paint: the grid's green stroke, the closed + // line's red fill, and the smooth line. + let latest = plot_drawings(&all); + let paints = |d: &nsh_core::canvas::Drawing, op: &CanvasOp| d.ops.contains(op); + let green = CanvasOp::StrokeStyle(Paint::Color(Color(0x00ff_00ff))); + let red = CanvasOp::FillStyle(Paint::Color(Color(0xff00_0080))); + let pick = |keep: &dyn Fn(&nsh_core::canvas::Drawing) -> bool| { + latest.iter().filter(|(_, d)| keep(d)).collect::>() + }; + let grids = pick(&|d| paints(d, &green)); + let closed = pick(&|d| paints(d, &red)); + let smooth = pick(&|d| !paints(d, &green) && !paints(d, &red)); + let ([(_, grid)], [(a_id, a)], [(_, b)]) = (&grids[..], &closed[..], &smooth[..]) else { + panic!("a grid and two lines: {latest:?}") + }; + // The grid: rings at 5 and 10 of 0 to 10 (25 and 50 out from the + // center, 100 by 50), squares through the four rows' angles, and a + // spoke out from the center at each. + assert_eq!( + grid.ops[0], + CanvasOp::StrokeStyle(Paint::Color(Color(0x00ff_00ff))) + ); + let rings = path_points(&grid.ops); + near( + &rings[..8], + &[ + (100.0, 25.0), + (125.0, 50.0), + (100.0, 75.0), + (75.0, 50.0), + (100.0, 0.0), + (150.0, 50.0), + (100.0, 100.0), + (50.0, 50.0), + ], + ); + near(&rings[8..10], &[(100.0, 50.0), (100.0, 0.0)]); + assert_eq!(rings.len(), 16); + // The closed line: its fill, then its stroke from 12 o'clock round + // (10, 5, 10 and 0 of 0 to 10) and back to its first point. + assert_eq!( + a.ops[0], + CanvasOp::FillStyle(Paint::Color(Color(0xff00_0080))) + ); + let line = line_ops(&a.ops); + assert_eq!( + line[0], + CanvasOp::StrokeStyle(Paint::Color(Color(0xff00_00ff))) + ); + near( + &path_points(line), + &[ + (100.0, 0.0), + (125.0, 50.0), + (100.0, 100.0), + (100.0, 50.0), + (100.0, 0.0), + ], + ); + assert!(line.contains(&CanvasOp::Path(PathOp::Close))); + assert!( + a.ops + .contains(&CanvasOp::Fill(None, nsh_core::canvas::FillRule::NonZero)) + ); + // The smooth one: from 10 out (5 of 0 to 10 at 25) to 40 (80% of 50), + // a cubic to each next row, open. + let line = line_ops(&b.ops); + near( + &path_points(line), + &[(100.0, 25.0), (140.0, 50.0), (100.0, 75.0), (60.0, 50.0)], + ); + assert!(!line.contains(&CanvasOp::Path(PathOp::Close))); + + let plot = test_view(&all, "plot"); + let hover = |x: f32, y: f32| { + engine.control().dispatch_event( + plot, + EventKind::MouseMove, + EventPayload::Mouse(nsh_core::MouseEvent::new( + point(px(x), px(y)), + point(px(x), px(y)), + Default::default(), + None, + )), + ) + }; + // Where the animator puts the views it moves: their bound translations + // (a hover dot's come after the half-size ones that center it, so + // they are its transform list's third and fourth entries). + let moved = |all: &[MountBatch]| { + let mut animator = Animator::default(); + for item in all.iter().flat_map(|b| b.items.iter()) { + animator.apply(item); + } + let mut out = Vec::new(); + animator.write(Some, |_, values, _| { + if let [x, y] = values + .transform + .iter() + .flatten() + .copied() + .collect::>()[..] + { + out.push((x, y)); + } + }); + out + }; + // Near 12 o'clock, past the smooth line's outer radius but within the + // closed one's: the first row. + hover(110.0, 10.0); + all.extend(until(&rx, |b| shows(b, "hover 0"))); + // Near 3 o'clock: the second row, the smooth line's hover dot on its + // point there. + hover(140.0, 55.0); + all.extend(until(&rx, |b| shows(b, "hover 1"))); + all.extend(until(&rx, |_| false)); + let dots = moved(&all); + assert!( + dots.iter() + .any(|(x, y)| (x - 140.0).abs() < 1e-3 && (y - 50.0).abs() < 1e-3), + "{dots:?}" + ); + // Below 9 o'clock, nearer it than 6: the last row. + hover(70.0, 60.0); + all.extend(until(&rx, |b| shows(b, "hover 3"))); + // Outside every line's outer radius: none. + hover(195.0, 5.0); + all.extend(until(&rx, |b| shows(b, "hover null"))); + + // A new value: the closed line's second point moves from 25 out to + // 50 at 3 o'clock over the update, on the animator. + let more = test_view(&all, "more"); + engine + .control() + .dispatch_event(more, EventKind::Press, EventPayload::None); + let redrawn = until_drawn(&rx, |d| { + d.ops + .iter() + .any(|op| matches!(op, CanvasOp::Path(PathOp::LineTo(Num::Slot(_), _)))) + }); + all.extend(redrawn); + all.extend(until(&rx, |_| false)); + let drawn = plot_drawings(&all); + let (_, a) = drawn.iter().find(|(id, _)| id == a_id).unwrap(); + // The first point that moves: the second row's, at 3 o'clock. + let slot = line_ops(&a.ops) + .iter() + .find_map(|op| match op { + CanvasOp::Path(PathOp::LineTo(Num::Slot(x), _)) => Some(*x), + _ => None, + }) + .expect("the moving point"); + let xs = slot_at(&all, *a_id, slot, &[1.0, 1.125, 1.3]); + assert!((xs[0] - 125.0).abs() < 1e-3, "{xs:?}"); + assert!(xs[1] > 126.0 && xs[1] < 149.0, "{xs:?}"); + assert!((xs[2] - 150.0).abs() < 1e-3, "{xs:?}"); +} + +const BASE_PLOT_RADAR_APPEAR: &str = r##"import { mount, View } from "nsh"; +import { Plot, RadialLine } from "nsh/base/plot"; + +function App() { + return ( + + + + + + ); +} + +mount(App); +"##; + +/// A radial line grows out of its inner radius as the plot appears +/// (`gpui-component`'s radar), its points animated values on the +/// platform's animator: the first, at 12 o'clock, from the center up to +/// the plot's top edge (the y domain 0 to 2, the plot's own extent). +#[test] +fn base_plot_radial_line_grows_out_as_the_plot_appears() { + use nsh_core::canvas::{CanvasOp, Num, PathOp}; + let (_engine, rx) = start(BASE_PLOT_RADAR_APPEAR); + let mut all = until_drawn(&rx, |d| { + d.ops + .iter() + .any(|op| matches!(op, CanvasOp::Path(PathOp::MoveTo(_, Num::Slot(_))))) + }); + all.extend(until(&rx, |_| false)); + let drawn = plot_drawings(&all); + let (id, line) = &drawn[0]; + let slot = line_ops(&line.ops) + .iter() + .find_map(|op| match op { + CanvasOp::Path(PathOp::MoveTo(Num::Const(x), Num::Slot(y))) if *x == 50.0 => Some(*y), + _ => None, + }) + .expect("its first point, moving up"); + let ys = slot_at(&all, *id, slot, &[1.0, 1.15, 1.3]); + assert!((ys[0] - 50.0).abs() < 1e-3, "{ys:?}"); + assert!(ys[1] > 1.0 && ys[1] < 49.0, "{ys:?}"); + assert!(ys[2].abs() < 1e-3, "{ys:?}"); } const BASE_PLOT_ARC_RETARGET: &str = r##"import { mount, signal, Text, View } from "nsh"; @@ -6941,6 +9111,142 @@ fn base_plot_bars_grow_and_fade_natively() { assert_eq!(bar_rects(&drawings[0].1.ops).len(), 3); } +const BASE_PLOT_COLOR_SCALE: &str = r##"import { mount, View } from "nsh"; +import { Area, Bar, Line, Pie, Plot } from "nsh/base/plot"; + +type Row = { region: string; v: number }; + +const rows: Row[] = [ + { region: "EU", v: 1 }, + { region: "US", v: 2 }, + { region: "APAC", v: 3 }, + { region: "EU", v: 4 }, + { region: "LATAM", v: 5 }, +]; + +function App() { + return ( + + {/* The domain's order, the categories past it in the order met, the + range repeating; the last slice's class over its color. */} + + (i === 4 ? "fill-[#999999]" : "")} /> + + {/* Bars by the app's function of a row; a style over the scale. */} + + (d.v > 2 ? "high" : "low")} /> + + + {/* A series' first row: rows of its own outside the domain take + `unknown`; without a color, `currentColor` (the plot's text). */} + + + + + + + + ); +} + +mount(App); +"##; + +/// A plot's `colorScale` (`gpui-base`'s `ScaleOrdinal`, d3's +/// `scaleOrdinal`) colors its marks by the category their `color` reads +/// of each row (a field, or the app's function a turn later): a pie's +/// slices and the bars by their rows, a line and an area by their first +/// row. The domain's categories take the range in order, the others the +/// next colors as they are met (d3's implicit domain) or `unknown`; the +/// range repeats. The color is the paint where nothing sets one: the +/// app's classes and styles win. +#[test] +fn base_plot_colors_marks_through_an_ordinal_scale() { + use nsh_core::{ + Color, + canvas::{CanvasOp, Paint}, + }; + let (red, green, blue, gray) = ( + Color(0xff00_00ff), + Color(0x00ff_00ff), + Color(0x0000_ffff), + Color(0x9999_99ff), + ); + let (_engine, rx) = start(BASE_PLOT_COLOR_SCALE); + // The bars' function answers a turn after the rows. + let mut all = until_drawn(&rx, |d| { + !bar_rects(&d.ops).is_empty() + && d.ops + .contains(&CanvasOp::FillStyle(Paint::Color(Color(0x00ff_00ff)))) + }); + all.extend(until(&rx, |_| false)); + let drawings = plot_drawings(&all); + let fills = |d: &nsh_core::canvas::Drawing| -> Vec { + d.ops + .iter() + .filter_map(|op| match op { + CanvasOp::FillStyle(Paint::Color(c)) => Some(*c), + _ => None, + }) + .collect() + }; + let stroke = |d: &nsh_core::canvas::Drawing| { + d.ops.iter().find_map(|op| match op { + CanvasOp::StrokeStyle(Paint::Color(c)) => Some(*c), + _ => None, + }) + }; + + // The slices, in the rows' order: EU (the domain's second), US (its + // first), APAC and LATAM met after it (the third color, then the + // first again), the last one's class over its color. + let slices: Vec<_> = drawings + .iter() + .filter(|(_, d)| !drawn_arcs(&d.ops).is_empty()) + .map(|(_, d)| fills(d)) + .collect(); + assert_eq!( + slices, + [vec![green], vec![red], vec![blue], vec![green], vec![gray]] + ); + + // Each bar its row's color; the style's fill over every one. + let bars: Vec<_> = drawings + .iter() + .filter(|(_, d)| !bar_rects(&d.ops).is_empty()) + .collect(); + let [(_, by_value), (_, styled)] = &bars[..] else { + panic!("two bars: {bars:?}") + }; + assert_eq!(fills(by_value), [red, red, green, green, green]); + assert_eq!(fills(styled), [gray; 5]); + + // A line of its own rows (US: unknown), one of the plot's (EU first), + // one without a color; the area of the plot's rows. + let lines: Vec<_> = drawings + .iter() + .filter(|(_, d)| { + d.ops.iter().any(|op| matches!(op, CanvasOp::Stroke(_))) + && !d.ops.iter().any(|op| matches!(op, CanvasOp::Fill(..))) + }) + .filter_map(|(_, d)| stroke(d)) + .collect(); + let [own, plots, plain] = lines[..] else { + panic!("three lines: {lines:?}") + }; + assert_eq!((own, plots), (blue, green)); + assert_eq!(plain, Color(0x3333_33ff)); + let area = drawings + .iter() + .find(|(_, d)| { + drawn_arcs(&d.ops).is_empty() + && bar_rects(&d.ops).is_empty() + && d.ops.iter().any(|op| matches!(op, CanvasOp::Fill(..))) + }) + .map(|(_, d)| fills(d)); + assert_eq!(area, Some(vec![green])); +} + const BASE_PLOT_AXIS: &str = r##"import { mount, signal, View } from "nsh"; import { Bar, Line, Plot, PlotAxis } from "nsh/base/plot"; @@ -7094,6 +9400,44 @@ fn base_plot_axis_labels_the_ticks_of_its_scale() { near(&all, "0%", "linear", (180.0, 90.0)); } +const NAMED_CARD: &str = r#"import { mount, signal, Text } from "nsh"; +import { Card } from "test/ui"; + +const id = signal("a"); +setTimeout(() => id.set("b"), 20); +setTimeout(() => id.set(undefined), 60); + +mount(() => ( + + card + +)); +"#; + +/// A TSX app's `testID` on a Rust component names its root, follows the +/// app's changes and goes when the app removes it: the core's record of +/// the id a Rust view gives a component does not send it back. +#[test] +fn a_tsx_test_id_on_a_rust_component_follows_the_app() { + let (_engine, rx) = start(NAMED_CARD); + // The test ids of the card's node, each time its props went. + let mut card = None; + let mut sent: Vec> = Vec::new(); + for batch in until(&rx, |_| false) { + for item in &batch.items { + if let MountItem::ViewProps { id, props } = item { + if card.is_none() && props.test_id.as_deref() == Some("a") { + card = Some(*id); + } + if card == Some(*id) { + sent.push(props.test_id.as_deref().map(str::to_owned)); + } + } + } + } + assert_eq!(sent, [Some("a".into()), Some("b".into()), None]); +} + const BASE_PLOT_DOMAIN_UPDATE: &str = r##"import { mount, signal, Text, View } from "nsh"; import { Line, Plot } from "nsh/base/plot"; @@ -7311,6 +9655,323 @@ fn base_plot_axis_moves_with_the_domain() { assert!(end.iter().all(|&x| x.abs() < 0.01), "{end:?}"); } +const BASE_PLOT_LABEL: &str = r##"import { mount, View } from "nsh"; +import { Bar, Line, Plot, PlotLabel } from "nsh/base/plot"; + +const SIZE = "w-[20px] h-[10px]"; + +function App() { + return ( + + + + + + + + + + + ); +} + +mount(App); +"##; + +/// The views of `all` up from `id` to `top`, their frames summed: where +/// `id` is in `top`'s coordinates. +fn frame_within(all: &[MountBatch], id: u32, top: u32) -> (f32, f32) { + let parent_of = |child: u32| { + all.iter() + .flat_map(|b| b.items.iter()) + .find_map(|i| match i { + MountItem::Insert { + parent, child: c, .. + } if *c == child => Some(*parent), + _ => None, + }) + .unwrap() + }; + let (mut x, mut y) = (0.0, 0.0); + let mut at = id; + while at != top { + let f = frame_of(all, at).unwrap(); + x += f.origin.x.0; + y += f.origin.y.0; + at = parent_of(at); + } + (x, y) +} + +/// `nsh/base/plot`'s `PlotLabel` (`gpui-base`'s `PlotLabel(Vec)`): +/// each text at its `x` and `y`, values of the plot's scales (a row's +/// index, or a fraction between two, on a point scale; the middle of a +/// band on a band scale; a value on a linear one), its box's top edge on +/// the point, and `align` starting it there (the default), centering it +/// on it or ending it there, as `gpui-base` paints a text at its origin. +#[test] +fn base_plot_label_writes_texts_at_values_of_the_scales() { + let (_engine, rx) = start(BASE_PLOT_LABEL); + let mut all = until(&rx, |b| shows(b, "band")); + all.extend(until(&rx, |_| false)); + let near = |text: &str, plot: &str, (x, y): (f32, f32)| { + let id = all + .iter() + .rev() + .find_map(|b| text_ids(b, text)) + .expect(text); + let (lx, ly) = frame_within(&all, id, test_view(&all, plot)); + assert!( + (lx - x).abs() <= 0.5 && (ly - y).abs() <= 0.5, + "{text} at ({lx}, {ly}), not ({x}, {y})" + ); + }; + // Row 1 of 3 is the middle of the plot's width, 50 of 0 to 100 the + // middle of its height. + near("left", "point", (100.0, 50.0)); + near("center", "point", (90.0, 50.0)); + near("right", "point", (80.0, 50.0)); + near("between", "point", (50.0, 0.0)); + // Band 2's middle (125 of 200, four bands). + near("band", "band", (115.0, 50.0)); +} + +const BASE_PLOT_LABEL_GLIDE: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Line, Plot, PlotLabel } from "nsh/base/plot"; + +function App() { + const top = signal(2); + setTimeout(() => top.set(4), 50); + return ( + + + + + + {`top ${top()}`} + + ); +} + +mount(App); +"##; + +/// A `PlotLabel` glides with its plot's domain (`motion.update`), as the +/// marks and the axes do: the text is laid out at its place on the new +/// scale (2 of 0 to 4: 100 of 200) and moved from where the old domain put +/// it (2 of 0 to 2: 100 further on) to there, on the platform's animator. +#[test] +fn base_plot_label_moves_with_the_domain() { + use nsh_core::animation::Animator; + let (_engine, rx) = start(BASE_PLOT_LABEL_GLIDE); + let mut all = until(&rx, |b| shows(b, "top 4")); + all.extend(until(&rx, |_| false)); + let text = all + .iter() + .rev() + .find_map(|b| text_ids(b, "glide")) + .expect("glide"); + assert_eq!( + frame_within(&all, text, test_view(&all, "plot")), + (100.0, 0.0) + ); + let anchor = all + .iter() + .flat_map(|b| b.items.iter()) + .find_map(|i| match i { + MountItem::Insert { parent, child, .. } if *child == text => Some(*parent), + _ => None, + }) + .unwrap(); + let mut animator = Animator::default(); + for item in all.iter().flat_map(|b| b.items.iter()) { + animator.apply(item); + } + let moved = |animator: &mut Animator, t: f64| { + animator.tick(t); + let mut out = (f32::NAN, f32::NAN); + animator.write( + |id| (id == anchor).then_some(id), + |_, values, _| { + out = ( + values.transform[0].unwrap_or(f32::NAN), + values.transform[1].unwrap_or(f32::NAN), + ) + }, + ); + out + }; + let (x, y) = moved(&mut animator, 0.0); + assert!((x - 100.0).abs() < 0.01 && y.abs() < 0.01, "({x}, {y})"); + let (x, y) = moved(&mut animator, 0.25); + assert!(x.abs() < 0.01 && y.abs() < 0.01, "({x}, {y})"); +} + +const BASE_PLOT_APPEAR_SCOPE: &str = r##"import { mount, signal, Text, View } from "nsh"; +import { Line, Plot, PlotAppearScope } from "nsh/base/plot"; + +function App() { + const shown = signal(true); + const key = signal("a"); + const scope = signal(1); + const rows = [{ v: 1 }, { v: 2 }, { v: 3 }]; + return ( + + + + + + + + + {/* Outside a scope: made again, it draws in again. */} + + + + + + {`state ${shown()} ${key()} ${scope()}`} + shown.set(!shown())} style={{ height: 20 }} /> + key.set(key() === "a" ? "b" : "a")} style={{ height: 20 }} /> + scope.set(scope() + 1)} style={{ height: 20 }} /> + + ); +} + +mount(App); +"##; + +/// `PlotAppearScope` (`gpui-base`'s): a plot made again inside it (a row +/// a list brings back) whose generation finished there shows whole at +/// once and starts no appear, where one outside a scope draws in again; +/// a new generation replays, one taken away before it finished draws in +/// again, a plot's last generation is forgotten once it finishes another, +/// and a new `id` draws the scope's plots in afresh while they stay. +#[test] +fn base_plot_appear_scope_remembers_the_appears_that_finished() { + use nsh_core::{ + animation::{AnimationOp, Driver, Finished}, + canvas::CanvasOp, + }; + // The last appear of `ms` started (its time, 0 to 1). + let started = |batches: &[MountBatch], ms: f32| { + batches + .iter() + .rev() + .flat_map(|b| b.items.iter().rev()) + .find_map(|i| match i { + MountItem::Animation(AnimationOp::Start { + animation, + driver: + Driver::Timing { + to, duration_ms, .. + }, + .. + }) if *to == 1.0 && *duration_ms == ms => Some(*animation), + _ => None, + }) + }; + let clipped = |batches: &[MountBatch]| { + plot_drawings(batches) + .iter() + .filter(|(_, d)| d.ops.iter().any(|op| matches!(op, CanvasOp::Clip(..)))) + .count() + }; + let (engine, rx) = start(BASE_PLOT_APPEAR_SCOPE); + let settled = |batches: &mut Vec| batches.extend(until(&rx, |_| false)); + let mut first = until(&rx, |b| shows(b, "state true a 1")); + settled(&mut first); + let (Some(inside), Some(outside)) = (started(&first, 300.0), started(&first, 200.0)) else { + panic!("both plots draw in") + }; + let finish = |animations: &[u32]| { + engine.control().animations_finished( + animations + .iter() + .map(|&animation| Finished::new(animation, true)) + .collect(), + ); + until(&rx, |_| false) + }; + let done = finish(&[inside, outside]); + assert_eq!(plot_drawings(&done).len(), 2); + assert_eq!(clipped(&done), 0); + + let press = |id: &str| { + engine + .control() + .dispatch_event(test_view(&first, id), EventKind::Press, EventPayload::None) + }; + let after = |id: &str, state: &str| { + press(id); + let mut batches = until(&rx, |b| shows(b, state)); + settled(&mut batches); + batches + }; + // Made again: whole at once inside the scope, drawn in again outside. + after("toggle", "state false a 1"); + let back = after("toggle", "state true a 1"); + assert_eq!(started(&back, 300.0), None); + let Some(outside) = started(&back, 200.0) else { + panic!("the plot outside the scope draws in again") + }; + assert_eq!(clipped(&back), 1); + finish(&[outside]); + + // A new generation replays, the other plot stays. + let replayed = after("key", "state true b 1"); + assert!(started(&replayed, 300.0).is_some()); + assert_eq!(started(&replayed, 200.0), None); + // Taken away before it finished: drawn in again from the start. + after("toggle", "state false b 1"); + let back = after("toggle", "state true b 1"); + let (Some(inside), Some(outside)) = (started(&back, 300.0), started(&back, 200.0)) else { + panic!("an unfinished appear replays") + }; + finish(&[inside, outside]); + // Its last generation was forgotten when this one finished. + let again = after("key", "state true a 1"); + let Some(inside) = started(&again, 300.0) else { + panic!("going back replays") + }; + finish(&[inside]); + after("toggle", "state false a 1"); + let back = after("toggle", "state true a 1"); + let Some(outside) = started(&back, 200.0) else { + panic!("the plot outside the scope draws in again") + }; + assert_eq!(started(&back, 300.0), None); + finish(&[outside]); + + // A new id forgets: the plot in the scope draws in again where it is. + let renamed = after("rename", "state true a 2"); + assert!(started(&renamed, 300.0).is_some()); + assert_eq!(started(&renamed, 200.0), None); +} + const BASE_PLOT_KEYS: &str = r##"import { mount, signal, Text, View } from "nsh"; import { Line, Pie, Plot } from "nsh/base/plot"; @@ -8061,19 +10722,4 @@ mount(App); near(x.expect("dot x"), line_x); near(y.expect("dot y"), line_y); } - // A distinct pointer-selected row still animates, rather than making - // the bug fix a blanket removal of pointer motion. - hover(0.0, 99.0); - let mut moved = until(&rx, |b| shows(b, "top=200 events=2 row=0")); - moved.extend(until(&rx, |_| false)); - assert!( - moved.iter().flat_map(|b| &b.items).any(|item| matches!( - item, - MountItem::Animation(AnimationOp::Start { - driver: Driver::Spring { .. }, - .. - }) - )), - "changing the selected row retains pointer spring motion" - ); } diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/counter.rs b/third-party/nsh/crates/nsh-engine/tests/engine/counter.rs index f9aeacc84f..0d9c0b6bc4 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/counter.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/counter.rs @@ -35,6 +35,7 @@ fn config() -> EngineConfig { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/errors.rs b/third-party/nsh/crates/nsh-engine/tests/engine/errors.rs index eda2a67208..0c6a610367 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/errors.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/errors.rs @@ -38,6 +38,7 @@ fn config(bundle: Bundle, limits: nsh_engine::Limits) -> EngineConfig { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -122,6 +123,7 @@ fn first_error(bundle: Bundle) -> String { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs b/third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs index 997ebe5307..01bf626bac 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/fetch.rs @@ -124,6 +124,7 @@ fn fetch_round_trips_through_the_platform_network() { trace: false, network: Some(network), websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -202,7 +203,10 @@ impl Network for RedirectNetwork { const CHECKED_APP: &str = r#" import { mount, signal, Text } from "nsh"; const text = signal("loading"); -const outcome = (p) => p.then((r) => r.text().then((t) => `${t}${r.redirected ? " (redirected)" : ""}`), (e) => e.message); +const outcome = (p) => p.then( + (r) => r.text().then((t) => `${t}${r.redirected ? " (redirected)" : ""}`), + (e) => `${e.name} ${e.code} ${e.permission} ${e.resource}: ${e.message}`, +); Promise.all([ outcome(fetch("https://evil.io/")), outcome(fetch("https://api.example.com/hop/https://api.example.com/final")), @@ -237,6 +241,7 @@ fn fetch_stays_within_the_capabilities() { trace: false, network: Some(Arc::new(RedirectNetwork { seen: seen.clone() })), websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -262,13 +267,17 @@ fn fetch_stays_within_the_capabilities() { ); let result = texts(&rx.recv_timeout(Duration::from_secs(5)).unwrap()).join(""); let parts: Vec<&str> = result.split(" | ").collect(); + // Refused requests and redirects reject as every failed fetch, with a + // `TypeError`, and say what was refused. assert!( - parts[0].contains("`evil.io` to capabilities.network"), + parts[0].starts_with("TypeError ERR_ACCESS_DENIED network https://evil.io/: ") + && parts[0].contains("`evil.io` to capabilities.network"), "{result}" ); assert_eq!(parts[1], "ok (redirected)"); assert!( - parts[2].contains("https://evil.io/steal is not granted"), + parts[2].starts_with("TypeError ERR_ACCESS_DENIED network https://evil.io/steal: ") + && parts[2].contains("https://evil.io/steal is not granted"), "{result}" ); // evil.io was never contacted. @@ -309,6 +318,7 @@ fn rust_components_fetch_through_the_platform_network() { trace: false, network: Some(network), websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/fs.rs b/third-party/nsh/crates/nsh-engine/tests/engine/fs.rs index d81923f4fc..ce876b194b 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/fs.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/fs.rs @@ -1,7 +1,8 @@ //! `fs/promises` through an engine: the module is the runtime's Rust //! registry (its JS face and declarations come from its spec); its grant -//! checks and limits throw in the call, its disk work settles as core -//! tasks. The probes are gpui-shell's, so its contract holds here too. +//! checks and limits run in the call, which rejects at once, its disk work +//! settles as core tasks. The probes are gpui-shell's, so its contract +//! holds here too. use std::{os::unix::fs::symlink, path::PathBuf, sync::atomic::AtomicUsize}; @@ -70,6 +71,7 @@ mount(() => {{shown()}}); trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -284,7 +286,9 @@ const absoluteLink = ABSOLUTE; const expected = ["default", "exists", "lstat", "mkdir", "readFile", "readdir", "readlink", "rename", "rmdir", "stat", "unlink", "writeFile"]; if (JSON.stringify(Object.keys(namespace).sort()) !== JSON.stringify(expected.sort())) throw Error("exports differ: " + Object.keys(namespace)); if (fs.readFile !== namespace.readFile) throw Error("default and named exports differ"); - let sync = 0; + // A function returning a promise never throws: wrong arguments reject + // with a TypeError, a path no grant covers with ERR_ACCESS_DENIED. + let usage = 0; for (const op of [ () => fs.readFile("invalid", "base64"), () => fs.readFile("invalid", {}), @@ -293,14 +297,17 @@ const absoluteLink = ABSOLUTE; () => fs.mkdir("never", { typo: true }), () => fs.readdir(".", { withFileTypes: 1 }), () => fs.readdir(".", { recursive: true }), - () => fs.exists("/outside-native-root"), () => fs.writeFile("never", new ArrayBuffer(4)), () => fs.writeFile("never", new Uint16Array(2)), () => fs.readFile(1), ]) { - try { op(); } catch (e) { if (e instanceof TypeError) sync++; } + let promise; + try { promise = op(); } catch { throw Error("a promise function threw"); } + await promise.then(() => {}, (e) => { if (e instanceof TypeError) usage++; }); } - if (sync !== 11) throw Error("sync checks " + sync); + if (usage !== 10) throw Error("usage rejections " + usage); + const outside = await fs.exists("/outside-native-root").then(() => "none", (e) => `${e.code}:${e.permission}:${e.resource}`); + if (outside !== "ERR_ACCESS_DENIED:fs:/outside-native-root") throw Error("denial " + outside); let getterCalls = 0; await fs.readdir(".", { get withFileTypes() { getterCalls++; return true; } }); if (getterCalls !== 1) throw Error("option getter called " + getterCalls + " times"); @@ -312,7 +319,7 @@ const absoluteLink = ABSOLUTE; const target = await fs.readlink("link"); if (!link.isSymbolicLink() || !target.endsWith("/secret")) throw Error("link was followed"); let denied = false; - try { await fs.readFile("link"); } catch (e) { denied = e.constructor === Error && !e.message.includes("capabilities.fs"); } + try { await fs.readFile("link"); } catch (e) { denied = e.constructor === Error && e.code !== "ERR_ACCESS_DENIED"; } if (!denied) throw Error("outside read allowed"); if (!(await fs.exists("link"))) throw Error("link missed"); if (!(await fs.exists("dangling")) || !(await fs.lstat("dangling")).isSymbolicLink()) throw Error("dangling link missed"); @@ -390,10 +397,10 @@ import fs from "fs/promises"; (async () => { let read = false; try { await fs.readFile("over-read-limit"); } catch (e) { read = e.constructor === Error && e.message.includes("67108864"); } - let write = false; - try { fs.writeFile("not-created", "x".repeat(8 * 1024 * 1024 + 1)); } catch (e) { write = e instanceof RangeError && e.message.includes("8388608-byte"); } + const refused = (p) => p.then(() => "written", (e) => `${e.constructor.name}:${e.code}`); + let write = await refused(fs.writeFile("not-created", "x".repeat(8 * 1024 * 1024 + 1))) === "RangeError:ERR_OUT_OF_RANGE"; // A buffer past the limit is refused by its length, before it is copied. - try { fs.writeFile("not-created", new Uint8Array(64 * 1024 * 1024)); write = false; } catch (e) { write = write && e instanceof RangeError && e.message.includes("67108864 bytes"); } + write = write && await refused(fs.writeFile("not-created", new Uint8Array(64 * 1024 * 1024))) === "RangeError:ERR_OUT_OF_RANGE"; const bytes = await fs.readFile("above-native-text-limit"); report(read && write && Object.getPrototypeOf(bytes) === Uint8Array.prototype && bytes.length === 16 * 1024 * 1024 + 1 ? "ok" : "bad"); })().catch((e) => report(String(e))); @@ -421,17 +428,18 @@ import fs from "fs/promises"; // Writes hold their data from the call; nothing settles within one turn. const data = "x".repeat(8 * 1024 * 1024); const writes = []; - let refused = false; - try { for (let i = 0; i < 9; i++) writes.push(fs.writeFile("w" + i, data)); } - catch (e) { refused = e instanceof RangeError && e.message.includes("in flight"); } - await Promise.all(writes); + for (let i = 0; i < 9; i++) writes.push(fs.writeFile("w" + i, data)); + const settled = await Promise.allSettled(writes); + const written = settled.filter((r) => r.status === "fulfilled").length; + const refused = settled.length === 9 && settled[8].status === "rejected" + && settled[8].reason.code === "ERR_NSH_QUOTA_EXCEEDED"; // Both reads are under way at once: the second does not fit. const reads = (await Promise.allSettled([fs.readFile("first"), fs.readFile("second")])) - .map((r) => r.status === "fulfilled" ? r.value.length : r.reason.message); - const one = reads.includes(40 * 1024 * 1024) && reads.some((r) => String(r).includes("in flight")); + .map((r) => r.status === "fulfilled" ? r.value.length : r.reason.code); + const one = reads.includes(40 * 1024 * 1024) && reads.includes("ERR_NSH_QUOTA_EXCEEDED"); // Settled data returns to the budget. const again = (await fs.readFile("second")).length === 40 * 1024 * 1024; - report(`${writes.length} ${refused} ${one} ${again}`); + report(`${written} ${refused} ${one} ${again}`); })().catch((e) => report(String(e))); "#, DATA_GRANT, @@ -447,17 +455,13 @@ fn explicit_absolute_roots_work_without_an_app_data_directory() { let (_engine, rx) = start( r#" import fs from "fs/promises"; -try { fs.readdir("/home"); report("listed"); } -catch (e) { report(`${e instanceof TypeError} ${e.message}`); } +fs.readdir("/home").then(() => report("listed"), (e) => report(`${e.code} ${e.permission} ${e.resource}`)); "#, "{}", None, Default::default(), ); - assert_eq!( - shown(&rx, "done"), - "done true filesystem read is not granted by capabilities.fs.read" - ); + assert_eq!(shown(&rx, "done"), "done ERR_ACCESS_DENIED fs /home"); let dir = scratch("absolute").canonicalize().unwrap(); let file = serde_json::to_string(&dir.join("plain")).unwrap(); let grant = serde_json::json!({"fs": {"read": [dir], "write": [dir]}}).to_string(); @@ -534,7 +538,7 @@ import fs, {{ writeFile, readFile, exists }} from "fs/promises"; (async () => {{ let rejected = 0; for (const operation of [() => fs.writeFile({p}, "x"), () => writeFile({p}, "x"), () => fs.readFile({p}), () => readFile({p}), () => exists({p})]) {{ - try {{ await operation(); }} catch (e) {{ if (e instanceof TypeError && e.message.includes("capabilities.fs")) rejected++; }} + try {{ await operation(); }} catch (e) {{ if (e.code === "ERR_ACCESS_DENIED" && e.permission === "fs" && e.resource === {p}) rejected++; }} }} report(rejected === 5 ? "ok" : `bad ${{rejected}}`); }})().catch((e) => report(String(e))); @@ -611,11 +615,13 @@ fn files_are_unavailable_on_a_mobile_surface() { let entry = dir.join("App.tsx"); std::fs::write( &entry, - r#"import { mount, Text } from "nsh"; + r#"import { mount, signal, Text } from "nsh"; import fs from "fs/promises"; -let shown = ""; -try { fs.readFile("x"); } catch (e) { shown = `done ${e instanceof TypeError} ${e.message}`; } -mount(() => {shown}); +const shown = signal(""); +let promise; +try { promise = fs.readFile("x"); } catch { shown.set("done threw"); } +promise?.then(() => "read", (e) => `done ${e.constructor.name} ${e.code}`).then((text) => shown.set(text)); +mount(() => {shown()}); "#, ) .unwrap(); @@ -637,6 +643,7 @@ mount(() => {shown}); trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -661,7 +668,7 @@ mount(() => {shown}); let _engine = Engine::start(config, || ApproxTextEngine, ChannelSink(tx)).unwrap(); assert_eq!( shown(&rx, "done"), - "done true native filesystem unavailable on this platform" + "done Error ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" ); let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(dir); @@ -678,8 +685,8 @@ fn failures_carry_node_s_codes() { import fs from "fs/promises"; const failure = (p) => p.then(() => "none", (e) => `${e.constructor.name}:${e.code}:${e.syscall}:${e.path}`); (async () => { - let denied = ""; - try { fs.readFile("/outside-every-root"); } catch (e) { denied = `${e.constructor.name}:${e.code}:${e.path}`; } + const denied = await fs.readFile("/outside-every-root") + .then(() => "none", (e) => `${e.constructor.name}:${e.code}:${e.permission}:${e.resource}`); const codes = [ denied, await failure(fs.stat("nowhere")), @@ -700,7 +707,7 @@ const failure = (p) => p.then(() => "none", (e) => `${e.constructor.name}:${e.co ); assert_eq!( shown(&rx, "done"), - "done TypeError:EACCES:/outside-every-root\ + "done Error:ERR_ACCESS_DENIED:fs:/outside-every-root\ |Error:ENOENT:stat:nowhere\ |Error:ENOENT:open:nowhere\ |Error:EEXIST:mkdir:full\ diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/host.rs b/third-party/nsh/crates/nsh-engine/tests/engine/host.rs index 204993ea9e..71f2c95918 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/host.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/host.rs @@ -20,7 +20,9 @@ fn with_host() -> Environment { } /// Calls each of `names` with `{ a: 1 }` and shows `available()` and what -/// each call settled with. +/// each call settled with: its value, or the error's class, `code`, +/// `permission` and `resource` (and the host's message, which its errors +/// keep). fn calls(names: &[&str]) -> String { let names = serde_json::to_string(names).unwrap(); format!( @@ -29,7 +31,10 @@ import {{ host }} from "nsh"; const shown = signal("waiting"); const settled = async (p) => {{ - try {{ return JSON.stringify(await p); }} catch (e) {{ return `rejected ${{e.message}}`; }} + try {{ return JSON.stringify(await p); }} catch (e) {{ + const host = e.code === "ERR_NSH_HOST_ERROR" ? e.message : ""; + return `rejected ${{[e.name, e.code, e.permission, e.resource, host].filter(Boolean).join(" ")}}`; + }} }}; setTimeout(async () => {{ const results = []; @@ -107,7 +112,7 @@ fn host_call_round_trips_through_the_platform() { // Without a host, nothing reaches the platform and calls reject. let (engine, rx) = start_with(&calls(&["echo"]), Environment::default(), echo, None); let commands = serve_host(&engine, &rx, |t| { - t == "false rejected nsh/host: this platform has no host" + t == "false rejected Error ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" }); assert!(commands.is_empty(), "{commands:?}"); } @@ -123,7 +128,7 @@ fn a_failing_host_call_rejects_with_its_message() { } done }); - assert_eq!(shown, "true rejected boom"); + assert_eq!(shown, "true rejected Error ERR_NSH_HOST_ERROR boom"); } #[test] @@ -148,10 +153,7 @@ fn host_call_is_denied(capabilities: Option<&str>) { done }); assert!(commands.is_empty(), "{commands:?}"); - assert!( - shown.starts_with("true rejected ") && shown.contains("capabilities.host"), - "{shown}" - ); + assert_eq!(shown, "true rejected Error ERR_ACCESS_DENIED host echo"); // The refusal is logged, saying which key to add. let logged = crate::logs::lines_containing("nsh/host: `echo` is not granted"); assert!( @@ -177,7 +179,10 @@ fn host_allowlist_matches_prefixes() { done }); assert_eq!(names(&commands), ["bridge.call"]); - assert!(shown.starts_with(r#"true {"a":1} | rejected "#), "{shown}"); + assert_eq!( + shown, + r#"true {"a":1} | rejected Error ERR_ACCESS_DENIED host other"# + ); } const EVENTS: &str = r#"import { mount, signal, Text } from "nsh"; @@ -188,7 +193,7 @@ let once = 0; let all = 0; const first = host.on("tick", () => { once++; - first.remove(); + first(); }); host.on("tick", (payload) => { all++; @@ -230,7 +235,7 @@ const shown = signal("waiting"); setTimeout(() => {{ host.call("slow", null, {options}).then( (value) => shown.set(`ok ${{JSON.stringify(value)}}`), - (e) => shown.set(`rejected ${{e.message}}`), + (e) => shown.set(`rejected ${{e.name}}${{e.code ? " " + e.code : ""}}`), ); }}, 0); @@ -269,13 +274,9 @@ fn reply_with_id(engine: &Engine, id: serde_json::Value, value: serde_json::Valu #[test] fn a_host_call_times_out_and_its_late_reply_is_ignored() { let (engine, rx) = start_with(&timed("{ timeout: 200 }"), with_host(), ALL, None); - let batches = until(&rx, |b| { - shows(b, "rejected nsh/host: slow timed out after 200 ms") - }); + let batches = until(&rx, |b| shows(b, "rejected Error ETIMEDOUT")); assert!( - batches - .iter() - .any(|b| shows(b, "rejected nsh/host: slow timed out after 200 ms")), + batches.iter().any(|b| shows(b, "rejected Error ETIMEDOUT")), "{:?}", batches.iter().flat_map(texts).collect::>() ); @@ -366,9 +367,7 @@ fn host_function_names_outside_their_charset_are_refused() { let results: Vec<&str> = shown.trim_start_matches("true ").split(" | ").collect(); assert_eq!(results.len(), bad.len(), "{shown}"); assert!( - results - .iter() - .all(|r| r.starts_with("rejected nsh/host: invalid function name")), + results.iter().all(|r| *r == "rejected TypeError"), "{shown}" ); // The longest name and every character of the set pass. @@ -407,13 +406,13 @@ fn timeouts_past_the_clocks_reach_wait_without_limit() { #[test] fn negative_and_non_finite_timeouts_reject() { - for (timeout, shown) in [("-1", "-1"), ("NaN", "NaN"), ("Infinity", "inf")] { + for timeout in ["-1", "NaN", "Infinity"] { let options = format!("{{ timeout: {timeout} }}"); let (_engine, rx) = start_with(&timed(&options), with_host(), ALL, None); - let wanted = format!("rejected nsh/host: invalid timeout {shown}"); - let batches = until(&rx, |b| shows(b, &wanted)); + let wanted = "rejected RangeError"; + let batches = until(&rx, |b| shows(b, wanted)); assert!( - batches.iter().any(|b| shows(b, &wanted)), + batches.iter().any(|b| shows(b, wanted)), "{timeout}: {:?}", batches.iter().flat_map(texts).collect::>() ); diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/host_channel.rs b/third-party/nsh/crates/nsh-engine/tests/engine/host_channel.rs index 7c13531802..fcf0f95504 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/host_channel.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/host_channel.rs @@ -103,9 +103,9 @@ const off = host.on("ping", (d) => say(`ping ${d}`)); host.on("stop", () => off()); host.call("echo", { n: 1 }).then((v: any) => say(`echo ${v.got.n}`)); -host.call("fail").catch((e) => say(`fail ${e instanceof Error} ${e.message}`)); -host.call("missing").catch((e) => say(`missing ${e.message}`)); -host.call("dropped").catch((e) => say(`dropped ${e.message}`)); +host.call("fail").catch((e) => say(`fail ${e instanceof Error} ${e.code} ${e.message}`)); +host.call("missing").catch((e) => say(`missing ${e.code} ${e.message}`)); +host.call("dropped").catch((e) => say(`dropped ${e.code} ${e.message}`)); host.call("later", 21).then((v) => say(`later ${v}`)); host.post("analytics", { screen: "home", f: () => 1 }); try { @@ -153,9 +153,9 @@ fn the_app_and_its_host_talk() { "params c-42 object", "early 1", "echo 1", - "fail true nope", - "missing host.call(\"missing\"): the embedding app handles no \"missing\"", - "dropped host.call(\"dropped\"): the embedding app did not answer", + "fail true ERR_NSH_HOST_ERROR nope", + "missing ERR_NSH_HOST_ERROR host.call(\"missing\"): the embedding app handles no \"missing\"", + "dropped ERR_NSH_HOST_ERROR host.call(\"dropped\"): the embedding app did not answer", "later 42", "bad TypeError", "not a name TypeError", diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs b/third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs index f3e514d949..8b6ac91a32 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/host_modules.rs @@ -41,15 +41,32 @@ impl Host { NOTE.send_replace(note); } - /// Calls `on` with the host's note now and on every change. - pub(crate) async fn subscribe(on: Callback) { - let mut notes = NOTE.subscribe(); - loop { - on.post(notes.borrow_and_update().clone()); - if notes.changed().await.is_err() { - return; - } + /// Calls `listener` with the host's note now and on every change + /// (`change`), until the returned function is called or what called + /// `on` goes. + pub(crate) fn on( + event: String, + listener: Callback, + cx: CallCx<'_>, + ) -> Result { + if event != "change" { + return Err(format!("no Host event `{event}`")); } + let listener = std::rc::Rc::new(std::cell::RefCell::new(Some(listener))); + let held = listener.clone(); + cx.start(async move { + let mut notes = NOTE.subscribe(); + loop { + let Some(listener) = held.borrow().clone() else { + return; + }; + listener.post(notes.borrow_and_update().clone()); + if notes.changed().await.is_err() { + return; + } + } + }); + Ok(Stop::new(move || drop(listener.take()))) } } @@ -128,6 +145,7 @@ fn start( trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -210,12 +228,12 @@ import { nativeModule } from "nsh"; interface HostApi { publish(note: string): void; - subscribe(on: (note: string) => void): Promise; + on(event: "change", listener: (note: string) => void): () => void; } const { Host } = nativeModule<{ Host: HostApi }>("host/test"); const note = signal(""); -Host.subscribe((n) => note.set(n)); +Host.on("change", (n) => note.set(n)); // The first engine publishes; both hear it, as they hear the host's own. if (globalThis.publisher) setTimeout(() => Host.publish("from A"), 20); @@ -223,7 +241,7 @@ mount(() => {`note ${note()}`}); "#; #[test] -fn the_host_pushes_its_data_to_every_app_that_subscribed() { +fn the_host_pushes_its_data_to_every_app_that_listens() { let (_a, a) = start( &SYNC.replace("globalThis.publisher", "true"), vec![registry()], diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/hot.rs b/third-party/nsh/crates/nsh-engine/tests/engine/hot.rs index d4ab2b1a60..a87b4fbdc6 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/hot.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/hot.rs @@ -167,6 +167,7 @@ fn edits_replace_modules_and_keep_unrelated_state() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -326,6 +327,7 @@ fn entry_edits_keep_navigation_and_state() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/hover.rs b/third-party/nsh/crates/nsh-engine/tests/engine/hover.rs index ddacd40059..f1d202296a 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/hover.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/hover.rs @@ -64,6 +64,7 @@ fn start(name: &str) -> (Engine, Receiver) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/images.rs b/third-party/nsh/crates/nsh-engine/tests/engine/images.rs index f3be80dd7f..fa02db9e82 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/images.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/images.rs @@ -25,6 +25,15 @@ const QUIET: Duration = Duration::from_millis(300); /// `app` (TSX), bundled, compiled and started on a surface at /// `pixel_ratio`. fn start(app: &str, pixel_ratio: f32) -> (Engine, Receiver) { + start_with(app, pixel_ratio, Vec::new()) +} + +/// [`start`] on a host that serves `ui_sets` (`nsh/base`). +fn start_with( + app: &str, + pixel_ratio: f32, + ui_sets: Vec, +) -> (Engine, Receiver) { static APPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let dir = std::env::temp_dir().join(format!( "nsh-variants-{}-{}", @@ -38,16 +47,22 @@ fn start(app: &str, pixel_ratio: f32) -> (Engine, Receiver) { entry, minify: false, split: false, - ui_sets: Vec::new(), + ui_sets: ui_sets.iter().map(Into::into).collect(), }) .unwrap(); - let bytecode = nsh_js::compile_bytecode_with(&bundle.code, false, &[]).unwrap(); + let bytecode = nsh_js::compile_bytecode_with( + &bundle.code, + false, + &ui_sets.iter().map(Into::into).collect::>(), + ) + .unwrap(); let (tx, rx) = crossbeam_channel::unbounded(); let config = EngineConfig { environment: Environment { pixel_ratio, ..Environment::default() }, + ui_sets, // SAFETY: compiled just now by this runtime. ..EngineConfig::new(nsh_engine::App::Js(unsafe { Bundle::bytecode(bytecode) })) }; @@ -429,3 +444,41 @@ URL.revokeObjectURL(url); assert!(revoked.starts_with("blob:"), "{revoked}"); assert_eq!(revoked_bytes, &None); } + +/// A Rust component's image prop takes a `blob:` URL as ``'s `src` +/// does: `nsh/base`'s `Avatar` loads its blob's bytes, by the same key as +/// an `` of the URL (one digest, shared bytes), and a revoked URL +/// keeps its URL and no bytes, so the avatar shows its fallback. +#[test] +fn a_components_image_prop_resolves_blob_urls() { + const APP: &str = r#"import { mount, View, Image } from "nsh"; +import { Avatar } from "nsh/base"; + +const photo = new Blob([new Uint8Array([137, 80, 78, 71, 4, 5, 6])], { type: "image/png" }); +const url = URL.createObjectURL(photo); +const revoked = URL.createObjectURL(new Blob([new Uint8Array([9])])); +URL.revokeObjectURL(revoked); + +mount(() => ( + + + + + +)); +"#; + let (_engine, rx) = start_with(APP, 1.0, vec![nsh_base::registry()]); + let mounted: Vec<_> = sent(&rx).iter().flat_map(inline_images).collect(); + assert_eq!(mounted.len(), 3, "{mounted:?}"); + // The avatar's image and the host image of the URL: one key, its bytes + // (in whichever order they are mounted). + let (resolved, failed): (Vec<_>, Vec<_>) = mounted.iter().partition(|(_, d)| d.is_some()); + assert_eq!(resolved.len(), 2, "{mounted:?}"); + assert_eq!(resolved[0], resolved[1]); + let (key, bytes) = resolved[0]; + assert!(key.starts_with("data:image/png;nsh-key="), "{key}"); + assert_eq!(bytes.as_deref(), Some(&[137, 80, 78, 71, 4, 5, 6][..])); + // The revoked URL's avatar: its URL and no bytes. + assert_eq!(failed.len(), 1, "{mounted:?}"); + assert!(failed[0].0.starts_with("blob:"), "{mounted:?}"); +} diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs b/third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs index 8b041505dc..6f07c4a5e3 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/js_touch.rs @@ -71,6 +71,7 @@ fn start() -> (Engine, Receiver) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs b/third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs index 028a584315..59d7498bbf 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/keymap.rs @@ -48,6 +48,7 @@ fn start(app: &str) -> (Engine, Receiver) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/list.rs b/third-party/nsh/crates/nsh-engine/tests/engine/list.rs index da2870f30e..42f94e2ab4 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/list.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/list.rs @@ -216,6 +216,7 @@ fn config(app: &str) -> (EngineConfig, std::path::PathBuf) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -428,12 +429,10 @@ mount(App); /// The props `List` does not use itself reach its scroll view, as they do /// a ``'s: the test id and accessibility (UI tests find the list), /// classes, touch props and layout events; and they follow their signals. -/// (Imported from `nsh/list`, where `List` was first: it still is.) #[test] fn list_passes_host_props_to_its_scroll_view() { let app = r#" -import { mount, signal, Text, View } from "nsh"; -import { List } from "nsh"; +import { List, mount, signal, Text, View } from "nsh"; const label = signal("Feed"); const size = signal("none"); function App() { @@ -540,6 +539,8 @@ struct Paced { rx: mpsc::Receiver, seen: std::sync::Arc, native: NativeTree, + /// Text views made so far. + created: usize, list: Option, pressable: Option, dir: std::path::PathBuf, @@ -558,6 +559,7 @@ impl Paced { rx, seen, native: Default::default(), + created: 0, list: None, pressable: None, dir, @@ -582,6 +584,10 @@ impl Paced { id, kind: ViewKind::Scroll, } => self.list = self.list.or(Some(*id)), + MountItem::Create { + kind: ViewKind::Text, + .. + } => self.created += 1, MountItem::Events { id, events } if events.contains(nsh_core::EventKind::Press) => { @@ -771,6 +777,45 @@ fn prepending_a_row_never_shows_a_cell_with_its_neighbours_row() { assert_eq!(paced.errors(), Vec::::new()); } +/// A row added at the start of an inverted list (a chat's new message) +/// leaves the other cells on their rows: rows made again by key +/// (``) are not made again, only the new one is made. +#[test] +fn prepending_a_row_makes_only_that_row() { + let app = r#" +import { List, mount, signal, Text, View } from "nsh"; +const rows = signal(Array.from({ length: 100 }, (_, i) => ({ label: `row ${i}` }))); +function App() { + return ( + + rows.update((r) => [{ label: "new" }, ...r])} style={{ height: 0 }} /> + + {(row) => ( + + {(label) => {label()}} + + )} + + + ); +} +mount(App); +"#; + let mut paced = Paced::start(app); + paced.settle(|_, _| {}); + assert!(paced.shows(0, 10), "{:?}", paced.native.texts); + let before = paced.created; + paced.engine.control().dispatch_event( + paced.pressable.expect("the pressable view"), + nsh_core::EventKind::Press, + nsh_core::EventPayload::None, + ); + paced.settle(|_, _| {}); + assert!(paced.native.texts.values().any(|t| t == "new")); + assert_eq!(paced.created - before, 1, "rows made again"); + assert_eq!(paced.errors(), Vec::::new()); +} + /// A fling that moves the window faster than cells bind: once it stops, /// only rows of the window it stopped at are bound (requests for windows /// already left are dropped, not bound late). diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/native.rs b/third-party/nsh/crates/nsh-engine/tests/engine/native.rs index 5416e779f5..d54e04e25d 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/native.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/native.rs @@ -66,6 +66,7 @@ fn start(app: &str) -> (Engine, Receiver) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/net.rs b/third-party/nsh/crates/nsh-engine/tests/engine/net.rs index 2603ef1a8f..387e594ace 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/net.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/net.rs @@ -1,6 +1,7 @@ //! `net`'s sockets through an engine: the module is the runtime's Rust -//! registry (its JS face and declarations come from its spec); its limits -//! throw in the call, its connections and reads and writes are core tasks. +//! registry (its JS face and declarations come from its spec); its grant +//! and limits are checked in the call, which rejects at once; its +//! connections and reads and writes are core tasks. use std::{ io::{Read, Write}, @@ -56,6 +57,7 @@ mount(() => {{shown()}}); trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -85,7 +87,7 @@ fn sockets(port: u16) -> String { } #[test] -fn a_socket_round_trips_bytes_and_checks_its_limits_in_the_call() { +fn a_socket_round_trips_bytes_and_rejects_past_its_limits() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let peer = std::thread::spawn(move || { @@ -104,12 +106,9 @@ fn a_socket_round_trips_bytes_and_checks_its_limits_in_the_call() { const socket = await net.connect({{ host: "127.0.0.1", port: {port} }}); if (net.connect !== connect) throw Error("default and named exports differ"); if (await socket.read(0) !== null) throw Error("a zero read is null"); - let range = false; - try {{ socket.read(1048577); }} catch (e) {{ range = e instanceof RangeError && e.message === "socket read exceeded the 1048576 byte limit"; }} - if (!range) throw Error("the read limit throws in the call"); - range = false; - try {{ socket.write("x".repeat(1048577)); }} catch (e) {{ range = e instanceof RangeError && e.message === "socket write exceeded the 1048576 byte limit"; }} - if (!range) throw Error("the write limit throws in the call"); + const refused = (p) => p.then(() => "done", (e) => `${{e.constructor.name}}:${{e.code}}`); + if (await refused(socket.read(1048577)) !== "RangeError:ERR_OUT_OF_RANGE") throw Error("the read limit rejects"); + if (await refused(socket.write("x".repeat(1048577))) !== "RangeError:ERR_OUT_OF_RANGE") throw Error("the write limit rejects"); await socket.write("ping"); const data = await socket.read(); if (!(data instanceof Uint8Array) || String.fromCharCode(...data) !== "pong") throw Error("bad reply"); @@ -125,22 +124,22 @@ fn a_socket_round_trips_bytes_and_checks_its_limits_in_the_call() { } #[test] -fn a_socket_not_granted_throws_in_the_call() { +fn a_socket_not_granted_rejects_with_access_denied() { for capabilities in [ "{}".to_owned(), r#"{"network":{"hosts":["*"]}}"#.to_owned(), sockets(10), ] { let (_engine, rx) = start( - r#"try { connect({ host: "127.0.0.1", port: 9 }); report("connected"); } catch (e) { - report(`${e instanceof TypeError} ${e.message}`); -}"#, + r#"try { + connect({ host: "127.0.0.1", port: 9 }).then(() => report("connected"), (e) => + report(`${e.constructor.name} ${e.code} ${e.permission} ${e.resource}`)); +} catch { report("threw"); }"#, &capabilities, ); assert_eq!( shown(&rx, "done"), - "done true a socket to `127.0.0.1:9` is not granted; add \"127.0.0.1:9\" to \ - capabilities.network.sockets", + "done Error ERR_ACCESS_DENIED network 127.0.0.1:9", "{capabilities}" ); } @@ -161,17 +160,14 @@ fn closing_a_socket_ends_a_pending_read_and_write() { r#"(async () => {{ const s = await connect({{ host: "127.0.0.1", port: {port} }}); const chunk = "x".repeat(1048576); - let limited = false; const writes = []; - for (let i = 0; i < 5; i++) {{ - try {{ writes.push(s.write(chunk).then(() => "sent", (e) => String(e))); }} - catch (e) {{ limited = e instanceof RangeError && e.message.includes("4194304 byte limit"); }} - }} + for (let i = 0; i < 5; i++) writes.push(s.write(chunk).then(() => "sent", (e) => e.code)); const read = s.read(); await new Promise((r) => setTimeout(r, 50)); s.close(); const results = await Promise.all(writes); - report(`${{limited}} ${{await read}} ${{results.some((r) => r.includes("socket is closed"))}}`); + // The fifth is past the 4 MiB that may wait; close ends the others. + report(`${{results[4] === "ERR_NSH_QUOTA_EXCEEDED"}} ${{await read}} ${{results.includes("ERR_SOCKET_CLOSED")}}`); }})().catch((e) => report(String(e)));"# ), &sockets(port), @@ -192,8 +188,8 @@ fn sockets_are_capped_per_vm_and_a_closed_one_frees_its_place() { r#"(async () => {{ const pending = []; for (let i = 0; i < 32; i++) pending.push(connect({{ host: "127.0.0.1", port: {port} }})); - let capped = false; - try {{ connect({{ host: "127.0.0.1", port: {port} }}); }} catch (e) {{ capped = e instanceof RangeError; }} + const capped = await connect({{ host: "127.0.0.1", port: {port} }}) + .then(() => false, (e) => e.code === "ERR_NSH_QUOTA_EXCEEDED"); const all = await Promise.all(pending); all[0].close(); const again = await connect({{ host: "127.0.0.1", port: {port} }}); diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/open_url.rs b/third-party/nsh/crates/nsh-engine/tests/engine/open_url.rs index 94629d0ad7..219a377e25 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/open_url.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/open_url.rs @@ -1,7 +1,7 @@ //! `openUrl` of `nsh/device`: the platform opens what the embedding app's //! URL handler does not take, what the handler takes never reaches the -//! platform, and refused URLs and capabilities throw; a `TextView` with -//! `links="open"` opens its links. +//! platform, and refused URLs and capabilities reject; a `TextView` with +//! `links="open"` opens its links, and a `Link` its `href`. use std::time::Duration; @@ -109,11 +109,7 @@ const say = (s: string) => log.set([...log(), s]); openUrl("https://example.com/a?b=1").then(({ opened }) => say(`web ${opened}`)); openUrl("mailto:ada@example.com").then(({ opened }) => say(`mail ${opened}`)); for (const url of ["javascript:alert(1)", "example.com", "file:///etc/passwd"]) { - try { - openUrl(url); - } catch (e) { - say(`refused ${(e as Error).name}`); - } + openUrl(url).catch((e: Error) => say(`refused ${e.name}`)); } mount(() => {log().join(" | ")}); @@ -218,15 +214,104 @@ fn a_text_view_opens_its_links_when_asked() { drop(engine); } +const LINK: &str = r#"import { mount, signal, Text, View } from "nsh"; +import { Link } from "nsh/base"; + +const log = signal([]); +const say = (s: string) => log.set([...log(), s]); +mount(() => ( + + say(`open ${href}`)} onPress={() => say("press")}> + Docs + + say(`route ${href}`)}> + #42 + + + Home + + {`log ${log().join(" | ")}`} + +)); +"#; + +/// `nsh/base`'s `Link` through its JS face: the link role, its label and +/// the current page for screen readers, no style of its own; a press opens +/// a site through the system after `onOpen`, and `openWith="app"` leaves +/// the opening to the app. +#[test] +fn a_link_opens_its_href_through_the_system_or_the_app() { + use nsh_core::{AccessibilityCurrent, AccessibilityRole, ViewProps}; + let (engine, rx) = start(bundle(LINK), &HostChannel::new()); + let first = rx + .recv_timeout(Duration::from_secs(5)) + .expect("first frame"); + let props = |test_id: &str| -> (u32, ViewProps) { + first + .items + .iter() + .find_map(|i| match i { + MountItem::ViewProps { id, props } if props.test_id.as_deref() == Some(test_id) => { + Some((*id, (**props).clone())) + } + _ => None, + }) + .expect(test_id) + }; + let (docs, docs_props) = props("docs"); + let (order, order_props) = props("order"); + let (home, home_props) = props("home"); + for p in [&docs_props, &order_props, &home_props] { + assert_eq!(p.accessibility.role, AccessibilityRole::Link); + } + assert_eq!(order_props.accessibility.label.as_deref(), Some("Order 42")); + assert_eq!(docs_props.accessibility.state.current, None); + assert_eq!( + home_props.accessibility.state.current, + Some(AccessibilityCurrent::Page) + ); + assert!(home_props.accessibility.state.disabled); + // No look of its own: only the app's `disabled:` class styles a link. + for id in [docs, order] { + assert!( + !first + .items + .iter() + .any(|i| matches!(i, MountItem::ViewStyle { id: v, .. } if *v == id)), + "no style of its own" + ); + } + // A disabled link takes no presses. + assert!(!first.items.iter().any(|i| matches!( + i, + MountItem::Events { id, events } if *id == home && events.contains(EventKind::Press) + ))); + + engine + .control() + .dispatch_event(docs, EventKind::Press, EventPayload::None); + let (_, urls) = run( + &engine, + &rx, + true, + &["open https://example.com/docs | press"], + ); + assert_eq!(urls, ["https://example.com/docs"]); + engine + .control() + .dispatch_event(order, EventKind::Press, EventPayload::None); + let (_, urls) = run(&engine, &rx, true, &["route /orders/42"]); + assert!(urls.is_empty(), "the app routes it: {urls:?}"); + drop(engine); +} + const GUARDED: &str = r#"import { mount, signal, Text } from "nsh"; import { openUrl } from "nsh"; const log = signal("start"); -try { - openUrl("https://example.com"); -} catch (e) { - log.set(`denied ${(e as Error).name} ${(e as Error).message}`); -} +openUrl("https://example.com").catch((e: Error & { code?: string; permission?: string; resource?: string }) => + log.set(`denied ${e.name} ${e.code} ${e.permission} ${e.resource} ${e.message}`), +); mount(() => {log()}); "#; @@ -237,7 +322,12 @@ fn opening_urls_is_a_capability() { Capabilities::from_json(&serde_json::json!({ "network": { "hosts": ["*"] } })).unwrap(), ); let (engine, rx) = start(app, &HostChannel::new()); - let (text, urls) = run(&engine, &rx, true, &["denied TypeError"]); + let (text, urls) = run( + &engine, + &rx, + true, + &["denied Error ERR_ACCESS_DENIED openUrl https://example.com "], + ); assert!(text.contains("\"openUrl\": true"), "{text}"); assert!(urls.is_empty()); drop(engine); diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/package.rs b/third-party/nsh/crates/nsh-engine/tests/engine/package.rs index 4cb914b92e..4ab4f4c281 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/package.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/package.rs @@ -45,6 +45,7 @@ fn first_texts(bundle: nsh_engine::Bundle) -> Vec { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/parity.rs b/third-party/nsh/crates/nsh-engine/tests/engine/parity.rs index d44dc277bf..c1518cb612 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/parity.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/parity.rs @@ -108,6 +108,7 @@ mount(() => {{shown()}}); trace: false, network, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -338,7 +339,8 @@ report({ /// chat `main.js`: `process.run` for versions, `process.spawn` with /// `{ lines: true, stdin: "null" | "pipe", cwd }`, chained `on("stdout")` /// / `on("stderr")` / `on("exit")`, `write(line)` and `kill()`; signed and -/// disktree: `run(...)` results' `code` / `stdout` / `stderr`. +/// disktree: `run(...)` results' `code` / `stdout` / `stderr` (and +/// NSH's `signal`). #[test] fn process_calls_of_the_bench_apps() { let app = App::start( @@ -347,7 +349,7 @@ import process from "process"; (async () => { const out = {}; const ran = await process.run("/bin/sh", ["-c", "echo out; echo err >&2; exit 3"]); - out.run = { code: ran.code, stdout: ran.stdout, stderr: ran.stderr, keys: Object.keys(ran).sort() }; + out.run = { code: ran.code, signal: ran.signal, stdout: ran.stdout, stderr: ran.stderr, keys: Object.keys(ran).sort() }; const ok = await process.run("/bin/sh", ["-c", "pwd"], { cwd: process.dataDir }); out.cwd = ok.stdout.trim() === process.dataDir && ok.code === 0; // chat: a one-shot provider, read line by line. @@ -383,10 +385,11 @@ import process from "process"; |_| {}, ); let out = app.result(); - // gpui-shell's `Output`: exactly `code`, `stdout`, `stderr`. + // gpui-shell's `Output`: `code`, `stdout`, `stderr`; NSH adds the + // `signal` that ended the child (`null` when it exited, as here). assert_eq!( out["run"], - serde_json::json!({ "code": 3, "stdout": "out\n", "stderr": "err\n", "keys": ["code", "stderr", "stdout"] }), + serde_json::json!({ "code": 3, "signal": null, "stdout": "out\n", "stderr": "err\n", "keys": ["code", "signal", "stderr", "stdout"] }), "{out}" ); assert_eq!(out["cwd"], true); diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/platform.rs b/third-party/nsh/crates/nsh-engine/tests/engine/platform.rs index a1b390899a..04d82c9b61 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/platform.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/platform.rs @@ -1,6 +1,9 @@ //! The OS the platform reports decides `platform` and which platform-only //! props (`ios={{…}}`, `android={{…}}`, `desktop={{…}}`, `web={{…}}`) //! apply; on the web, the idiom decides between mobile and desktop. +//! `platform.desktop`, the `desktop` idiom, `select`'s `desktop` and +//! `desktop={{…}}` all follow the one definition of desktop +//! (`Environment::desktop`), whatever idiom the platform set. use std::time::Duration; @@ -21,7 +24,7 @@ mount( desktop={{ accessibilityLabel: "desktop" }} web={{ accessibilityHint: "web" }} > - {`${platform.os} ${platform.idiom === "desktop"} ${platform.select({ ios: "i", mobile: "m", desktop: "d" })} ${platform.select({ native: "n", default: "-" })}`} + {`${platform.os} ${platform.idiom} ${platform.desktop} ${platform.catalyst} ${platform.select({ ios: "i", mobile: "m", desktop: "d" })} ${platform.select({ native: "n", default: "-" })}`} ), ); @@ -85,6 +88,7 @@ fn start( trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -223,14 +227,46 @@ fn platform_props_and_select_follow_the_os() { "{os:?} {idiom:?}" ); }; - frame(Os::Ios, Idiom::Phone, "ios false i n", &["ios"]); - frame(Os::Android, Idiom::Phone, "android false m n", &["android"]); - frame(Os::MacOs, Idiom::Desktop, "macos true d n", &["desktop"]); + frame(Os::Ios, Idiom::Phone, "ios phone false false i n", &["ios"]); + frame( + Os::Android, + Idiom::Phone, + "android phone false false m n", + &["android"], + ); + frame( + Os::MacOs, + Idiom::Desktop, + "macos desktop true false d n", + &["desktop"], + ); + // A desktop OS is desktop whatever idiom it was given. + frame( + Os::Linux, + Idiom::Phone, + "linux desktop true false d n", + &["desktop"], + ); + // UIKit's Mac idiom (Mac Catalyst) is mobile: the iOS backend. This + // build is not Catalyst's, so `catalyst` is false. + frame(Os::Ios, Idiom::Mac, "ios mac false false i n", &["ios"]); + // No mobile OS reports "desktop". + frame( + Os::Ios, + Idiom::Desktop, + "ios phone false false i n", + &["ios"], + ); // `native` is not the web, as in React Native. - frame(Os::Web, Idiom::Phone, "web false m -", &["web"]); - frame(Os::Web, Idiom::Pad, "web false m -", &["web"]); + frame(Os::Web, Idiom::Phone, "web phone false false m -", &["web"]); + frame(Os::Web, Idiom::Pad, "web pad false false m -", &["web"]); // A desktop browser behaves as a desktop. - frame(Os::Web, Idiom::Desktop, "web true d -", &["desktop", "web"]); + frame( + Os::Web, + Idiom::Desktop, + "web desktop true false d -", + &["desktop", "web"], + ); } #[test] diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/process.rs b/third-party/nsh/crates/nsh-engine/tests/engine/process.rs index 6ac20748fe..3de60a7689 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/process.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/process.rs @@ -78,6 +78,7 @@ mount(() => {{shown()}}); trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -193,28 +194,43 @@ fn run_keeps_split_utf8_and_captures_both_pipes() { ); assert_eq!( app.shown("done"), - r#"done {"code":0,"stdout":"€","stderr":"warning"}"# + r#"done {"code":0,"signal":null,"stdout":"€","stderr":"warning"}"# ); } #[test] -fn a_refused_command_and_a_failed_launch_throw_and_run_rejects_a_failed_launch() { +fn run_reports_the_signal_that_ended_the_child() { let app = App::start( - r#"const lines = []; -const shape = (e) => `${e.constructor.name} ${e.code} ${e.syscall} ${e.path} ${e.message.includes("capability denied")}`; -for (const command of ["definitely-not-granted", "/nonexistent/nsh-fixture"]) { - try { process.spawn(command); lines.push("spawned"); } catch (e) { lines.push(shape(e)); } + r#"process.run("/bin/sh", ["-c", "kill -TERM $$"]) + .then((r) => report(`${r.code} ${r.signal}`), (e) => report(String(e)));"#, + shell(), + ); + assert_eq!(app.shown("done"), "done null SIGTERM"); } -try { process.run("not-granted"); } catch (e) { lines.push(shape(e)); } -process.run("/nonexistent/nsh-fixture").then(() => report("ran"), (e) => report(`${lines.join("|")}|${shape(e)}`));"#, + +#[test] +fn a_refused_command_and_a_failed_launch_throw_and_run_rejects_both() { + let app = App::start( + r#"(async () => { + const lines = []; + const shape = (e) => `${e.constructor.name} ${e.code} ${e.syscall} ${e.path}`; + const denial = (e) => `${e.constructor.name} ${e.code} ${e.permission} ${e.resource}`; + try { process.spawn("definitely-not-granted"); lines.push("spawned"); } catch (e) { lines.push(denial(e)); } + try { process.spawn("/nonexistent/nsh-fixture"); lines.push("spawned"); } catch (e) { lines.push(shape(e)); } + let refused; + try { refused = process.run("not-granted"); } catch { lines.push("threw"); } + lines.push(await refused.then(() => "ran", denial)); + lines.push(await process.run("/nonexistent/nsh-fixture").then(() => "ran", shape)); + report(lines.join("|")); +})().catch((e) => report(String(e)));"#, shell(), ); assert_eq!( app.shown("done"), - "done TypeError EACCES spawn definitely-not-granted definitely-not-granted true|\ - Error ENOENT spawn /nonexistent/nsh-fixture /nonexistent/nsh-fixture false|\ - TypeError EACCES spawn not-granted not-granted true|\ - Error ENOENT spawn /nonexistent/nsh-fixture /nonexistent/nsh-fixture false" + "done Error ERR_ACCESS_DENIED process definitely-not-granted|\ + Error ENOENT spawn /nonexistent/nsh-fixture /nonexistent/nsh-fixture|\ + Error ERR_ACCESS_DENIED process not-granted|\ + Error ENOENT spawn /nonexistent/nsh-fixture /nonexistent/nsh-fixture" ); } @@ -267,15 +283,15 @@ fn dropping_the_engine_kills_every_child_at_once_even_when_they_ignore_sigterm() fn run_rejects_a_timeout_and_output_past_its_limit() { let app = App::start( r#"Promise.all([ - process.run("/bin/sh", ["-c", "sleep 30"], { timeoutMs: 25 }).then(() => "ran", (e) => String(e)), + process.run("/bin/sh", ["-c", "sleep 30"], { timeoutMs: 25 }).then(() => "ran", (e) => `${e.constructor.name}:${e.code}`), // `head` is found on the child's PATH, which the host passes on. - process.run("/bin/sh", ["-c", "head -c 9437184 /dev/zero"]).then(() => "ran", (e) => String(e)), + process.run("/bin/sh", ["-c", "head -c 9437184 /dev/zero"]).then(() => "ran", (e) => `${e.constructor.name}:${e.code}`), ]).then((r) => report(r.join("|")));"#, shell(), ); assert_eq!( app.shown("done"), - "done Error: process timed out|Error: process output exceeded 8388608 bytes on one stream" + "done Error:ETIMEDOUT|RangeError:ERR_CHILD_PROCESS_STDIO_MAXBUFFER" ); } @@ -385,7 +401,7 @@ fn the_cwd_is_the_data_directory_or_one_within_a_write_grant() { const lines = [String(pwd === process.dataDir)]; for (const cwd of ["${pluginDir}", "${homeDir}", "${pluginDir}/escape"]) { try { lines.push((await process.run("/bin/sh", ["-c", "pwd"], { cwd })).stdout.trim().split("/").pop()); } - catch (e) { lines.push(e.message.includes("not granted") ? "refused" : e.message); } + catch (e) { lines.push(e.code === "ERR_ACCESS_DENIED" && e.permission === "fs" && e.resource === cwd ? "refused" : e.message); } } report(lines.join("|")); })().catch((e) => report(String(e)));"#, @@ -410,10 +426,10 @@ fn the_cwd_is_the_data_directory_or_one_within_a_write_grant() { #[test] fn timeouts_signals_and_exit_errors_have_their_shapes() { let app = App::start( - r#"let thrown = false; -try { process.run("/bin/sh", [], { timeoutMs: -1 }); } catch (e) { thrown = e instanceof TypeError; } -const child = process.spawn("/bin/sh", ["-c", "sleep 20"], { timeoutMs: 20 }); -child.on("exit", (r) => report(`${thrown} ${r.code} ${r.signal} ${r.error}`));"#, + r#"process.run("/bin/sh", [], { timeoutMs: -1 }).then(() => false, (e) => e instanceof TypeError).then((rejected) => { + const child = process.spawn("/bin/sh", ["-c", "sleep 20"], { timeoutMs: 20 }); + child.on("exit", (r) => report(`${rejected} ${r.code} ${r.signal} ${r.error}`)); +});"#, shell(), ); assert_eq!( @@ -457,20 +473,23 @@ fn a_late_exit_listener_hears_the_exit_and_children_free_their_place() { } #[test] -fn past_the_children_limit_spawn_throws_and_run_rejects_a_range_error() { +fn past_the_children_limit_spawn_throws_and_run_rejects_a_quota_error() { let app = App::start( r#"(async () => { const children = []; for (let i = 0; i < 32; i++) children.push(process.spawn("/bin/sh", ["-c", "sleep 5"])); let thrown = ""; - try { process.spawn("/bin/sh", ["-c", "exit 0"]); } catch (e) { thrown = e.constructor.name; } - const rejected = await process.run("/bin/sh", ["-c", "exit 0"]).then(() => "ran", (e) => e.constructor.name); + try { process.spawn("/bin/sh", ["-c", "exit 0"]); } catch (e) { thrown = `${e.constructor.name}:${e.code}`; } + const rejected = await process.run("/bin/sh", ["-c", "exit 0"]).then(() => "ran", (e) => `${e.constructor.name}:${e.code}`); for (const child of children) child.kill(); report(`${thrown} ${rejected}`); })().catch((e) => report(String(e)));"#, shell(), ); - assert_eq!(app.shown("done"), "done RangeError RangeError"); + assert_eq!( + app.shown("done"), + "done Error:ERR_NSH_QUOTA_EXCEEDED Error:ERR_NSH_QUOTA_EXCEEDED" + ); } #[test] diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs index 7718449f78..cb56beef5c 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_animation.rs @@ -99,6 +99,7 @@ impl Platform { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs index aaa11dcfc1..d749ba36b7 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_app.rs @@ -47,6 +47,7 @@ fn start() -> (Engine, Receiver) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -128,6 +129,7 @@ fn a_rust_app_starts_in_its_appearance() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -186,6 +188,7 @@ fn rust_components_navigate_with_native_stacks() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -278,6 +281,7 @@ fn rust_components_pass_content_to_rust_components() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -383,6 +387,7 @@ fn rust_components_take_the_back_button() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -538,6 +543,7 @@ fn rust_components_hear_layouts_and_scrolls() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -616,6 +622,7 @@ fn nested_components_in_list_rows_reach_the_list_owner() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -684,6 +691,7 @@ fn content_a_component_does_not_place_is_not_shown() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs index d2a952aece..000728b9d2 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_classes.rs @@ -119,6 +119,9 @@ mod other { thread_local! { /// What `Tools.mark` recorded (on the engine's thread). static MARKS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + /// `Tools.listen`'s listeners (on the engine's thread). + static LISTENERS: std::cell::RefCell)>> = + const { std::cell::RefCell::new(Vec::new()) }; } /// Functions of the three kinds. @@ -187,6 +190,29 @@ impl Tools { MARKS.with_borrow(Clone::clone) } + /// Posts every `ping` to `heard` until the returned function is + /// called or what called it goes. + pub(crate) fn listen(heard: Callback) -> Stop { + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + LISTENERS.with_borrow_mut(|l| l.push((id, heard))); + Stop::new(move || LISTENERS.with_borrow_mut(|l| l.retain(|(i, _)| *i != id))) + } + + /// Posts `word` to every listener. + pub(crate) fn ping(word: String) { + LISTENERS.with_borrow(|l| { + for (_, heard) in l { + heard.post(word.clone()); + } + }); + } + + /// How many listen. + pub(crate) fn listeners() -> u32 { + LISTENERS.with_borrow(Vec::len) as u32 + } + /// An op that panics. pub(crate) fn explode(cx: &mut ModuleCx) -> i64 { let _ = cx; @@ -394,8 +420,8 @@ impl Sys { Ok(count) } - /// `n` doubled later: a negative one throws in the call, a zero one - /// rejects. + /// `n` doubled later: a negative one is refused in the call, a zero + /// one fails in the task (both reject). pub(crate) fn later(n: i32) -> Result, JsError> { if n < 0 { return Err(JsError::Range(format!("{n} is negative"))); @@ -510,6 +536,7 @@ pub(crate) fn start_with( trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -650,7 +677,10 @@ setTimeout(async () => { lines.push(msg(() => Sys.reversed([1, 2]))); lines.push(`${Sys.mayConnect(4000)} ${Sys.mayConnect(4001)}`); lines.push(`${Sys.take(2, true)} ${Sys.take(2, false)} ${msg(() => Sys.take(1, false))}`); - lines.push(msg(() => Sys.later(-1))); + let thrown = "no"; + let refused; + try { refused = Sys.later(-1); } catch { thrown = "yes"; } + lines.push(`thrown ${thrown} ${await settled(refused)}`); lines.push(await settled(Sys.later(21))); lines.push(await settled(Sys.later(0))); const e = new Emitter(); @@ -668,7 +698,7 @@ mount(() => {shown()}); "#; #[test] -fn calls_take_bytes_the_vms_system_and_start_tasks_that_throw_in_the_call() { +fn calls_take_bytes_the_vms_system_and_start_tasks_that_reject_what_they_refuse() { let (_engine, rx) = start_with( SYSTEM, vec![registry()], @@ -677,7 +707,7 @@ fn calls_take_bytes_the_vms_system_and_start_tasks_that_throw_in_the_call() { assert_eq!( shown(&rx, "done"), "done true 255,2,1|TypeError: Sys.reversed: `bytes`: expected a Uint8Array, got an array|\ - true false|1 2 RangeError: at most 1|RangeError: -1 is negative|42|Error: zero|\ + true false|1 2 RangeError: at most 1|thrown no RangeError: -1 is negative|42|Error: zero|\ a hi,b hi|null" ); } @@ -843,6 +873,47 @@ fn integers_take_the_same_values_in_every_kind_of_call_and_prop() { assert_eq!(rows.len(), 5, "{done}"); } +const STOPS: &str = r#"import { mount, signal, Text } from "nsh"; +import { Tools } from "test/classes"; + +const heard: string[] = []; +const shown = signal("waiting"); +const open = signal(true); +function Child() { + Tools.listen((w) => heard.push(`child ${w}`)); + return child; +} +const stop = Tools.listen((w) => heard.push(`top ${w}`)); +setTimeout(() => { + Tools.ping("a"); + setTimeout(() => { + const counts = [Tools.listeners()]; + stop(); + stop(); + counts.push(Tools.listeners()); + open.set(false); + counts.push(Tools.listeners()); + Tools.ping("b"); + setTimeout(() => shown.set(`done ${typeof stop} ${counts.join(" ")} ${heard.sort().join(",")}`), 10); + }, 10); +}, 0); + +mount(() => ( + <> + {shown()} + + + + +)); +"#; + +#[test] +fn a_stop_function_stops_once_and_goes_with_its_owner() { + let (_engine, rx) = start(STOPS, vec![registry()]); + assert_eq!(shown(&rx, "done"), "done function 2 1 0 child a,top a"); +} + const FRAGILE: &str = r#"import { mount, signal, Text } from "nsh"; import { Fragile } from "test/classes"; diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs index 9782763a0f..7af77ef483 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_lists.rs @@ -25,6 +25,7 @@ fn start(root: Root) -> (Engine, Receiver) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -449,6 +450,7 @@ fn start_js(app: &str) -> (Engine, Receiver) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs index 6f85c180d2..ad0727b13b 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_platform.rs @@ -47,6 +47,7 @@ fn config(app: App) -> EngineConfig { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs index 7b411405de..ef3606d284 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_popups.rs @@ -40,6 +40,7 @@ fn config(app: App) -> EngineConfig { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs index 83e3474e71..adabe87ee7 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_styles.rs @@ -73,6 +73,7 @@ fn start_root(root: Root) -> (Engine, Receiver) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs b/third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs index 6ebf0d79e3..1fda253d5f 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/rust_touch.rs @@ -88,7 +88,7 @@ fn until_command(rx: &Receiver) -> Vec { let done = batch .items .iter() - .any(|i| matches!(i, MountItem::Command { .. })); + .any(|i| matches!(i, MountItem::Command { command, .. } if !matches!(command, Command::Tokens(_)))); batches.push(batch); if done { break; @@ -109,7 +109,9 @@ fn drawing(batches: &[MountBatch]) -> Option> { fn command(batches: &[MountBatch]) -> Option<(u32, Command)> { batches.iter().find_map(|b| { b.items.iter().find_map(|i| match i { - MountItem::Command { id, command } => Some((*id, command.clone())), + MountItem::Command { id, command } if !matches!(command, Command::Tokens(_)) => { + Some((*id, command.clone())) + } _ => None, }) }) @@ -124,6 +126,7 @@ fn a_rust_element_claims_touches_in_a_scroll_view() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs b/third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs index 7b171186b6..2e3c27a47c 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/shutdown.rs @@ -30,6 +30,7 @@ fn stop_spinning(code: &str) -> (Duration, Vec) { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/split.rs b/third-party/nsh/crates/nsh-engine/tests/engine/split.rs index 0ec485b76b..0296abd574 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/split.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/split.rs @@ -94,6 +94,7 @@ fn config(bundle: Bundle) -> EngineConfig { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/storage.rs b/third-party/nsh/crates/nsh-engine/tests/engine/storage.rs index 9a80b12fa8..37bfa7e33e 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/storage.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/storage.rs @@ -63,6 +63,7 @@ fn launch( trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -116,7 +117,7 @@ setTimeout(async () => { Storage.removeItem("scratch"); const keys = await Storage.keys(); let full = "fits"; - await Storage.setItem("big", "x".repeat(6 * 1024 * 1024)).catch(() => (full = "full")); + await Storage.setItem("big", "x".repeat(6 * 1024 * 1024)).catch((e) => (full = e.code)); shown.set(`done ${launches} ${keys.join(",")} ${full} ${await Storage.getItem("missing")}`); } catch (e) { shown.set(`done error ${e}`); @@ -134,7 +135,7 @@ fn entries_last_across_launches() { let (engine, rx) = start("counter", COUNTER, Some("{}"), &data); assert_eq!( shown(&rx, "done"), - format!("done {launch} launches full null") + format!("done {launch} launches ERR_NSH_QUOTA_EXCEEDED null") ); // Stopping the engine drops the storage, which writes what is left. drop(engine); @@ -161,7 +162,10 @@ fn Reader(_props: ReaderProps) -> impl IntoElement { fn rust_shares_the_entries_of_js() { let data = scratch("shared"); let (engine, rx) = start("shared", COUNTER, None, &data); - assert_eq!(shown(&rx, "done"), "done 1 launches full null"); + assert_eq!( + shown(&rx, "done"), + "done 1 launches ERR_NSH_QUOTA_EXCEEDED null" + ); drop(engine); let (tx, rx) = crossbeam_channel::unbounded(); let config = EngineConfig { @@ -170,6 +174,7 @@ fn rust_shares_the_entries_of_js() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -193,7 +198,10 @@ fn rust_shares_the_entries_of_js() { drop(engine); // And the JS sees what Rust wrote. let (_engine, rx) = start("shared-again", COUNTER, None, &data); - assert_eq!(shown(&rx, "done"), "done 3 from_rust,launches full null"); + assert_eq!( + shown(&rx, "done"), + "done 3 from_rust,launches ERR_NSH_QUOTA_EXCEEDED null" + ); } const EDGES: &str = r#"import { mount, signal, Text } from "nsh"; @@ -267,6 +275,7 @@ fn rust_edits_the_storage() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -298,7 +307,10 @@ fn each_app_keeps_its_data_under_its_id() { ("notes", Some("com.example.notes")), ] { let (engine, rx) = launch(name, COUNTER, None, id, Some(&root), None); - assert_eq!(shown(&rx, "done"), "done 1 launches full null"); + assert_eq!( + shown(&rx, "done"), + "done 1 launches ERR_NSH_QUOTA_EXCEEDED null" + ); drop(engine); } // A second launch of one app finds its own data, not the other's. @@ -310,7 +322,10 @@ fn each_app_keeps_its_data_under_its_id() { Some(&root), None, ); - assert_eq!(shown(&rx, "done"), "done 2 launches full null"); + assert_eq!( + shown(&rx, "done"), + "done 2 launches ERR_NSH_QUOTA_EXCEEDED null" + ); drop(engine); // Without an id, or with one that is no directory name: `default`. for id in [None, Some("../escape")] { @@ -344,6 +359,7 @@ fn a_rust_root_keeps_its_data_under_its_id() { trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/system.rs b/third-party/nsh/crates/nsh-engine/tests/engine/system.rs index 445a646238..08dd3b60be 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/system.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/system.rs @@ -48,6 +48,7 @@ mount(() => {status()}); trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, @@ -142,6 +143,7 @@ mount(() => {status()});"#, trace: false, network: None, websocket: None, + refuse_connections: None, filesystem: None, assets: None, chunk_loader: None, diff --git a/third-party/nsh/crates/nsh-engine/tests/engine/test_ui.rs b/third-party/nsh/crates/nsh-engine/tests/engine/test_ui.rs index fb4f696ebc..ea100ee6d0 100644 --- a/third-party/nsh/crates/nsh-engine/tests/engine/test_ui.rs +++ b/third-party/nsh/crates/nsh-engine/tests/engine/test_ui.rs @@ -1,5 +1,5 @@ //! A UI set of the tests' own (`test/ui`): small components that exercise -//! the machinery of Rust components (slots, children placed one by one, +//! the machinery of Rust components (slots and how their content lays out, children placed one by one, //! virtualized `items`, `#[prop(lazy)]` children, `#[methods]` refs, events, //! inputs) as an app's crate would, with no UI set of nsh's. @@ -18,6 +18,7 @@ pub(crate) fn registry() -> Registry { .register::
+ + diff --git a/third-party/nsh/website/src/components/DocSearch.astro b/third-party/nsh/website/src/components/DocSearch.astro index 9a08631409..5c1bf0084f 100644 --- a/third-party/nsh/website/src/components/DocSearch.astro +++ b/third-party/nsh/website/src/components/DocSearch.astro @@ -17,7 +17,7 @@ const copy = isZh {copy.label} - + @@ -28,9 +28,9 @@ const copy = isZh

{copy.emptyBody}

diff --git a/third-party/nsh/website/src/components/Gallery.astro b/third-party/nsh/website/src/components/Gallery.astro index 482243fb72..fb9e4e7a43 100644 --- a/third-party/nsh/website/src/components/Gallery.astro +++ b/third-party/nsh/website/src/components/Gallery.astro @@ -6,6 +6,9 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { BASE, REPO_URL, url, type Lang } from '../lib/site'; +import Button from './ui/Button.astro'; +import ToggleGroup from './ui/ToggleGroup.astro'; +import ToggleGroupItem from './ui/ToggleGroupItem.astro'; export interface Props { lang?: Lang; @@ -52,11 +55,11 @@ const t = zh

{t.intro}

@@ -81,7 +84,10 @@ const t = zh const KEY = 'nsh-gallery-width'; const set = (mode: string) => { stage.dataset.mode = mode; - for (const b of buttons) b.setAttribute('aria-pressed', String(b.dataset.width === mode)); + for (const b of buttons) { + b.setAttribute('aria-checked', String(b.dataset.width === mode)); + b.tabIndex = b.dataset.width === mode ? 0 : -1; + } try { localStorage.setItem(KEY, mode); } catch {} @@ -92,7 +98,17 @@ const t = zh } catch {} // Phones see the phone layout whatever was chosen. set(saved === 'phone' || matchMedia('(max-width: 640px)').matches ? 'phone' : 'desktop'); - for (const b of buttons) b.addEventListener('click', () => set(b.dataset.width!)); + for (const [i, b] of buttons.entries()) { + b.addEventListener('click', () => set(b.dataset.width!)); + // A radio group: the arrows move the choice. + b.addEventListener('keydown', (e) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + const next = buttons[(i + (e.key === 'ArrowRight' ? 1 : buttons.length - 1)) % buttons.length]; + set(next.dataset.width!); + next.focus(); + e.preventDefault(); + }); + } // The app follows its frame's color scheme: the site's theme. if (frame) { const sync = () => (frame.style.colorScheme = document.documentElement.classList.contains('light') ? 'light' : 'dark'); @@ -118,40 +134,6 @@ const t = zh .gallery__head h1 { margin: 0; font-size: 1.75rem; letter-spacing: -0.02em; } .gallery__head p { margin: 0.35rem 0 0; color: var(--muted-foreground); max-width: 52rem; } .gallery__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; } - .gallery__seg { - display: inline-flex; - padding: 3px; - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--secondary); - } - .gallery__seg button { - height: 1.75rem; - padding: 0 0.85rem; - border: 0; - border-radius: calc(var(--radius-control) - 3px); - background: transparent; - color: var(--muted-foreground); - font: inherit; - font-size: 0.85rem; - cursor: pointer; - } - .gallery__seg button[aria-pressed="true"] { background: var(--finish-raised-bg); box-shadow: var(--finish-raised); color: var(--foreground); } - .gallery__btn { - display: inline-flex; - align-items: center; - height: 2.25rem; - padding: 0 0.95rem; - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--finish-raised-bg); - box-shadow: var(--finish-raised); - color: var(--foreground); - font-size: 0.85rem; - text-decoration: none; - } - .gallery__btn--primary { border-color: transparent; background: var(--finish-primary-bg); box-shadow: var(--finish-primary); color: var(--brand-contrast); font-weight: 600; } - .gallery__btn--primary:hover { background: var(--finish-primary-hover-bg); } .gallery__stage { margin-top: 1.25rem; display: flex; diff --git a/third-party/nsh/website/src/components/HeroField.astro b/third-party/nsh/website/src/components/HeroField.astro index 50a0d2e0be..73363f1900 100644 --- a/third-party/nsh/website/src/components/HeroField.astro +++ b/third-party/nsh/website/src/components/HeroField.astro @@ -1,29 +1,56 @@ --- -// The home hero's background: the site's dot grid drawn on a 2D canvas. -// Each dot has its own seeded size, brightness and twinkle, and a slowly -// drifting value-noise field decides where the grid shimmers, so the field -// is irregular and mostly dark. Near the pointer a few dots light up more -// than their neighbours and swell into the accent, inside a noise-perturbed -// reach that trails the pointer a little, over a small faint halo. The -// canvas never takes input: the listeners are on the hero, passive. It -// fades toward the copy, so text keeps its contrast. Colors are the -// `--hero-*` tokens below, read again when the theme changes. +// The home hero's background: the sky from close up, inside a galaxy, drawn +// by a fragment shader (WebGL). The galaxy's milky light, white leaning a +// little cool or warm by region, lying in belts as Jupiter's clouds do, around a core where the copy +// gives way to the device. Three layers of stars of different temperatures +// drift at their depth's pace and twinkle; the brightest wear diffraction +// spikes, and now and then a meteor crosses. Presses act on gravity: a held +// press is a small mass that bends the starlight toward it; released, it +// sends a gentle wave that carries the stars outward as it passes before +// they settle back, and turns their drift to a new heading. The canvas +// never takes input: the listeners are on the hero and the window, passive. +// The sky shows across the hero, about half as strong behind the copy, so +// text keeps its contrast; every mask is a smooth gradient. It holds still +// under reduced motion. Colors are the `--hero-*` tokens below, read again +// when the theme changes. // -// `?fieldperf` logs the mean draw time every 120 frames. +// `?fieldperf` logs the mean frame time every 120 frames. --- diff --git a/third-party/nsh/website/src/components/ui/TabsList.astro b/third-party/nsh/website/src/components/ui/TabsList.astro new file mode 100644 index 0000000000..8f506c29cd --- /dev/null +++ b/third-party/nsh/website/src/components/ui/TabsList.astro @@ -0,0 +1,8 @@ +--- +// shadcn/ui's TabsList: a track holding TabsTriggers. Panels and the +// switching stay with the page (each has its own `role="tabpanel"`). +import type { HTMLAttributes } from 'astro/types'; +type Props = HTMLAttributes<'div'> & { size?: 'default' | 'sm' | 'xs' }; +const { size = 'default', ...rest } = Astro.props; +--- +
diff --git a/third-party/nsh/website/src/components/ui/TabsTrigger.astro b/third-party/nsh/website/src/components/ui/TabsTrigger.astro new file mode 100644 index 0000000000..0fef90b7fe --- /dev/null +++ b/third-party/nsh/website/src/components/ui/TabsTrigger.astro @@ -0,0 +1,7 @@ +--- +// shadcn/ui's TabsTrigger: a tab (`aria-selected`, roving `tabindex`). +import type { HTMLAttributes } from 'astro/types'; +type Props = Omit, 'type'> & { selected?: boolean }; +const { selected = false, ...rest } = Astro.props; +--- + diff --git a/third-party/nsh/website/src/components/ui/Toggle.astro b/third-party/nsh/website/src/components/ui/Toggle.astro new file mode 100644 index 0000000000..84f802153f --- /dev/null +++ b/third-party/nsh/website/src/components/ui/Toggle.astro @@ -0,0 +1,12 @@ +--- +// shadcn/ui's Toggle: a two-state button (`aria-pressed`); the page's +// script flips the attribute. +import type { HTMLAttributes } from 'astro/types'; +type Props = Omit, 'type'> & { + variant?: 'default' | 'outline'; + size?: 'default' | 'sm' | 'xs' | 'lg'; + pressed?: boolean; +}; +const { variant = 'default', size = 'default', pressed = false, ...rest } = Astro.props; +--- + diff --git a/third-party/nsh/website/src/components/ui/ToggleGroup.astro b/third-party/nsh/website/src/components/ui/ToggleGroup.astro new file mode 100644 index 0000000000..a2b7137ebf --- /dev/null +++ b/third-party/nsh/website/src/components/ui/ToggleGroup.astro @@ -0,0 +1,26 @@ +--- +// shadcn/ui's ToggleGroup. `type="single"` is a radio group (its items are +// radios, `aria-checked`), `multiple` a group of toggles (`aria-pressed`). +// Variants `default`, `outline` and `segmented` (the look of Tabs: a track +// and a raised thumb); `spacing` is the gap between items in Tailwind's +// spacing steps (0 joins them, as shadcn's default). Items size from the +// group (`size`). +import type { HTMLAttributes } from 'astro/types'; +type Props = HTMLAttributes<'div'> & { + type?: 'single' | 'multiple'; + variant?: 'default' | 'outline' | 'segmented'; + size?: 'default' | 'sm' | 'xs' | 'lg'; + spacing?: number; +}; +const { type = 'single', variant = 'default', size = 'default', spacing = 0, style, ...rest } = Astro.props; +const gap = variant === 'segmented' ? undefined : `--gap: calc(var(--spacing) * ${spacing})`; +--- +
diff --git a/third-party/nsh/website/src/components/ui/ToggleGroupItem.astro b/third-party/nsh/website/src/components/ui/ToggleGroupItem.astro new file mode 100644 index 0000000000..a14a312c66 --- /dev/null +++ b/third-party/nsh/website/src/components/ui/ToggleGroupItem.astro @@ -0,0 +1,17 @@ +--- +// An item of a ToggleGroup: a radio of a `single` group (`aria-checked`, +// roving `tabindex`), a toggle of a `multiple` one (`aria-pressed`). The +// page's script switches the attributes; `count` is a quiet number after +// the label. +import type { HTMLAttributes } from 'astro/types'; +type Props = Omit, 'type'> & { + type?: 'single' | 'multiple'; + on?: boolean; + count?: number | string; +}; +const { type = 'single', on = false, count, ...rest } = Astro.props; +const state = type === 'single' + ? { role: 'radio', 'aria-checked': String(on), tabindex: on ? 0 : -1 } + : { 'aria-pressed': String(on) }; +--- + diff --git a/third-party/nsh/website/src/lib/code-tabs-client.ts b/third-party/nsh/website/src/lib/code-tabs-client.ts index ca7072cb4e..c2b58c9e5c 100644 --- a/third-party/nsh/website/src/lib/code-tabs-client.ts +++ b/third-party/nsh/website/src/lib/code-tabs-client.ts @@ -5,7 +5,7 @@ const KEY = 'codeTab'; function tabsOf(group: HTMLElement): HTMLButtonElement[] { - return [...group.querySelectorAll(':scope > [role="tablist"] > [role="tab"]')]; + return [...group.querySelectorAll(':scope > .code-tabs__head > [role="tablist"] > [role="tab"]')]; } function select(group: HTMLElement, key: string): boolean { diff --git a/third-party/nsh/website/src/lib/demo-client.ts b/third-party/nsh/website/src/lib/demo-client.ts index 58f188f6c8..e9247a07ca 100644 --- a/third-party/nsh/website/src/lib/demo-client.ts +++ b/third-party/nsh/website/src/lib/demo-client.ts @@ -36,10 +36,12 @@ function themeDemos(root: ParentNode) { onThemeChange(apply); } -/** The frame sizes and idioms of the layouts (`LAYOUTS` in `demo.js`). */ +/** The idioms of the layouts and their largest frames (`LAYOUTS` in + * `demo.js`); a panel's frame is as wide as fits, up to these, and the + * app lays out at that width, 1:1. */ const LAYOUTS = { mobile: { width: 390, height: 844, idiom: 'phone' }, - desktop: { width: 1024, height: 640, idiom: 'desktop' }, + desktop: { width: 1024, height: 900, idiom: 'desktop' }, } as const; type Layout = keyof typeof LAYOUTS; @@ -59,32 +61,93 @@ const withIdiom = (url: string, layout: Layout) => ? url.replace(/([?&]idiom=)[a-z]+/, `$1${LAYOUTS[layout].idiom}`) : url.replace('?', `?idiom=${LAYOUTS[layout].idiom}&`); +/** Frames never get shorter than this, and keep this much below the + * content (popups and dialogs lift out of the frame, `docs/dev/web.md`). */ +const MIN_HEIGHT = 420; +const ROOM_BELOW = 32; + +/** The height the app needs in `frame` (the player is on the site's + * origin): the bottom of the lowest thing it draws, not counting views that + * fill the frame (a page's background), or, when a view that fills it + * scrolls, the frame's height plus what is out of view; 0 when it cannot + * tell. */ +function contentHeight(frame: HTMLIFrameElement) { + try { + const doc = frame.contentDocument; + const app = doc?.getElementById('app'); + if (!doc || !app) return 0; + const full = doc.documentElement.clientHeight; + const view = doc.defaultView!; + let bottom = 0; + // `clip`: the bottom of the views around that clip what they hold (a + // virtual list's rows out of view); views that fill the frame do not. + const walk = (el: Element, clip: number) => { + const r = el.getBoundingClientRect(); + let inner = clip; + if (r.height >= full * 0.9) { + // Only scroll views (`nsh.js`'s `.nsh-scroll`) say they hold more. + const hidden = el.classList.contains('nsh-scroll') ? el.scrollHeight - el.clientHeight : 0; + if (hidden > 1) bottom = Math.max(bottom, full + hidden); + } else if (r.width > 0 && r.height > 0) { + const style = view.getComputedStyle(el); + // What draws: a leaf (text, an image, a border), or a view with a + // background; a bare container may stretch over empty room. + const draws = el.childElementCount === 0 || style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + if (draws && r.top < Math.min(full, clip)) bottom = Math.max(bottom, Math.min(full, clip, r.bottom)); + if (style.overflowY !== 'visible') inner = Math.min(clip, r.bottom); + } + for (const child of el.children) walk(child, inner); + }; + walk(app, Infinity); + return Math.ceil(bottom); + } catch { + return 0; + } +} + /** The Mobile / Desktop switch of a demo panel: the frame's size, the - * idiom the app runs with (it reloads), and a desktop frame scaled to the - * stage's width. */ + * idiom the app runs with (it reloads), and the frame's width, the stage's + * up to the layout's (never scaled: the app shows at its real size). */ function layoutDemo(demo: HTMLElement, all: HTMLElement[]) { const phone = demo.querySelector('.phone'); const frame = demo.querySelector('.phone__screen iframe'); const stage = demo.querySelector('.demo__stage'); const buttons = [...demo.querySelectorAll('[data-demo-layout]')]; if (!phone || !stage || buttons.length === 0) return; + // The height the app's content takes (`contentHeight`), once measured. + let content = 0; const fit = () => { - if (demo.dataset.layout !== 'desktop') { - phone.style.removeProperty('--phone-scale'); - return; + const size = LAYOUTS[(demo.dataset.layout as Layout) ?? 'mobile']; + const room = stage.clientWidth - parseFloat(getComputedStyle(stage).paddingLeft) * 2; + const width = Math.round(Math.max(280, Math.min(size.width, room))); + const height = content ? Math.min(size.height, Math.max(MIN_HEIGHT, content + ROOM_BELOW)) : size.height; + phone.style.setProperty('--phone-w', String(width)); + phone.style.setProperty('--phone-h', String(height)); + if (frame) { + frame.width = String(width); + frame.height = String(height); } - const room = stage.clientWidth - 40; - const scale = Math.max(0.3, Math.min(1, room / (LAYOUTS.desktop.width + 16))); - phone.style.setProperty('--phone-scale', scale.toFixed(3)); }; new ResizeObserver(fit).observe(stage); + // Each run measures again at the layout's full height, once it settles: + // the frame then ends below the content instead of a phone's height. + frame?.addEventListener('load', () => { + content = 0; + fit(); + for (const delay of [800, 2000]) { + setTimeout(() => { + const measured = contentHeight(frame); + if (measured && (!content || measured > content)) { + content = measured; + fit(); + } + }, delay); + } + }); const apply = (layout: Layout, save: boolean) => { if (demo.dataset.layout === layout && phone.dataset.layout === layout) return fit(); demo.dataset.layout = layout; phone.dataset.layout = layout; - const size = LAYOUTS[layout]; - phone.style.setProperty('--phone-w', String(size.width)); - phone.style.setProperty('--phone-h', String(size.height)); for (const b of buttons) { const on = b.dataset.demoLayout === layout; b.setAttribute('aria-checked', String(on)); @@ -94,8 +157,6 @@ function layoutDemo(demo: HTMLElement, all: HTMLElement[]) { link.href = withIdiom(link.href, layout); } if (frame?.dataset.playerSrc) { - frame.width = String(size.width); - frame.height = String(size.height); frame.dataset.playerSrc = withIdiom(frame.dataset.playerSrc, layout); // Loaded (or about to be): run it again with the new idiom, and the // page's theme (its `src` keeps the one it first loaded with). diff --git a/third-party/nsh/website/src/lib/demo.js b/third-party/nsh/website/src/lib/demo.js index 11c76a8419..80bf73b958 100644 --- a/third-party/nsh/website/src/lib/demo.js +++ b/third-party/nsh/website/src/lib/demo.js @@ -164,18 +164,18 @@ export async function demoPanelHtml(name, { base = '/', lang = 'en' } = {}) { return `
-
- - +
+ +
-
- - +
+ +
${escape(file)} -
- - +
+ +
diff --git a/third-party/nsh/website/src/lib/llms.ts b/third-party/nsh/website/src/lib/llms.ts index df06b34c5e..abeb62ab95 100644 --- a/third-party/nsh/website/src/lib/llms.ts +++ b/third-party/nsh/website/src/lib/llms.ts @@ -2,7 +2,7 @@ import { plainMarkdown } from './markdown-endpoint'; import { SECTIONS, pages } from './sections'; import { SITE_DESCRIPTION, SITE_TITLE, SITE_URL, type Lang } from './site'; -const HEADER = `# ${SITE_TITLE}\n\n> ${SITE_DESCRIPTION}\n\nApps are written in Rust (compiled to native code) or TSX (run by QuickJS), or both, on one native Rust runtime; the toolchain is the single \`nsh\` binary (\`nsh init my-app --template rust|ts\`, \`nsh run my-app\`, \`nsh package my-app --target \`). Every page below is also served as Markdown at its URL plus \`.md\`.\n`; +const HEADER = `# ${SITE_TITLE}\n\n> ${SITE_DESCRIPTION}\n\nApps are written in Rust (compiled to native code) or TSX (run by QuickJS, and by the browser's JavaScript engine on the web), or both, on one native Rust runtime; the toolchain is the single \`nsh\` binary (\`nsh init my-app --template rust|ts\`, \`nsh run my-app\`, \`nsh package my-app --target \`). Every page below is also served as Markdown at its URL plus \`.md\`.\n`; /** `llms.txt`: the table of contents, one line per page, linking its Markdown. */ export async function buildLlmsIndex(): Promise { diff --git a/third-party/nsh/website/src/lib/play-dev.js b/third-party/nsh/website/src/lib/play-dev.js index fde0850804..c6c6afadd6 100644 --- a/third-party/nsh/website/src/lib/play-dev.js +++ b/third-party/nsh/website/src/lib/play-dev.js @@ -1,7 +1,8 @@ // `astro dev` only: serves the player from its sources, so edits show on -// reload without `cargo xtask web` again. A demo (`play/demos/.nshc`) is -// compiled from `demos/.tsx` on request by the nsh CLI the player build -// made; `nsh.js` and `index.html` come from `crates/nsh-platform-web/web`. +// reload without `cargo xtask web` again. A demo (`play/demos/.js`, an +// ES module) is compiled from `demos/.tsx` on request by the nsh CLI the +// player build made; `nsh.js`, the browser host it imports (`browser/*.js`) +// and `index.html` come from `crates/nsh-platform-web/web`. // `nsh.wasm` is the one built by `bun run dev` (rebuilt on the next start: // the runtime of published apps, `nsh.wasm` next to the build's output), // read from the target directory (the built site's player loads the @@ -27,7 +28,7 @@ export function playDev(base) { configureServer(server) { server.watcher.add(join(website, 'demos')); server.watcher.add(galleryUi); - // Demo bytecode is compiled on request, outside Vite's module graph. + // Demos are compiled on request, outside Vite's module graph. // Reload its frames when the shared component layer or theme changes. server.watcher.on('change', (path) => { if (path.startsWith(`${galleryUi}/`)) server.ws.send({ type: 'full-reload' }); @@ -41,7 +42,7 @@ export function playDev(base) { res.setHeader('Cache-Control', 'no-store'); res.end(body); }; - if (rest === 'index.html' || rest === 'nsh.js') { + if (rest === 'index.html' || rest === 'nsh.js' || /^browser\/[\w-]+\.js$/.test(rest)) { const file = join(web, rest); if (!existsSync(file)) return next(); return send(rest.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'text/html; charset=utf-8', readFileSync(file)); @@ -51,7 +52,7 @@ export function playDev(base) { if (!existsSync(file)) return next(); return send(rest === 'nsh.wasm' ? 'application/wasm' : 'application/octet-stream', readFileSync(file)); } - const demo = /^demos\/([\w-]+)\.nshc$/.exec(rest); + const demo = /^demos\/([\w-]+)\.js$/.exec(rest); if (!demo || !existsSync(nsh)) return next(); const entry = join(website, 'demos', `${demo[1]}.tsx`); if (!existsSync(entry)) return next(); @@ -62,7 +63,7 @@ export function playDev(base) { res.statusCode = 500; return send('text/plain; charset=utf-8', built.stderr || built.stdout || 'nsh build failed'); } - return send('application/octet-stream', readFileSync(join(out, 'bundle.nshc'))); + return send('text/javascript; charset=utf-8', readFileSync(join(out, 'bundle.js'))); } finally { rmSync(out, { recursive: true, force: true }); } diff --git a/third-party/nsh/website/src/lib/playground-client.ts b/third-party/nsh/website/src/lib/playground-client.ts index 108f7f51ce..0d00bccf25 100644 --- a/third-party/nsh/website/src/lib/playground-client.ts +++ b/third-party/nsh/website/src/lib/playground-client.ts @@ -10,6 +10,7 @@ import { syntaxHighlighting } from '@codemirror/language'; import { classHighlighter } from '@lezer/highlight'; import { onThemeChange, siteTheme } from './demo-client'; import { inlineGalleryUi } from './inline-gallery-ui'; +import { mountSelect } from './ui/select'; // Each demo's source, loaded when picked. const DEMOS = import.meta.glob('/demos/*.tsx', { query: '?raw', import: 'default' }) as Record< @@ -69,98 +70,6 @@ async function decodeCode(text: string): Promise { return new TextDecoder().decode(await pipe(fromBase64Url(text), new DecompressionStream('deflate-raw'))); } -/** - * The example picker: a trigger button and a listbox popover (WAI-ARIA - * select-only combobox pattern, simplified). Focus moves to the list while - * it is open; `aria-activedescendant` names the option the keys point at. - */ -function mountPicker(el: HTMLElement, onPick: (value: string) => void) { - const trigger = el.querySelector('[data-trigger]')!; - const menu = el.querySelector('[data-menu]')!; - const current = el.querySelector('[data-current]')!; - const options = [...menu.querySelectorAll('[role="option"]')]; - let active = 0; - - const index = () => Math.max(0, options.findIndex((o) => o.dataset.value === el.dataset.value)); - const setActive = (i: number) => { - active = Math.max(0, Math.min(options.length - 1, i)); - options.forEach((o, k) => o.toggleAttribute('data-active', k === active)); - menu.setAttribute('aria-activedescendant', options[active].id); - options[active].scrollIntoView({ block: 'nearest' }); - }; - const isOpen = () => !menu.hidden; - const open = () => { - menu.hidden = false; - trigger.setAttribute('aria-expanded', 'true'); - setActive(index()); - menu.focus(); - }; - const close = (refocus = true) => { - if (!isOpen()) return; - menu.hidden = true; - trigger.setAttribute('aria-expanded', 'false'); - if (refocus) trigger.focus(); - }; - const set = (value: string) => { - el.dataset.value = value; - for (const o of options) o.setAttribute('aria-selected', String(o.dataset.value === value)); - current.textContent = `${value}.tsx`; - }; - const choose = (i: number) => { - const value = options[i].dataset.value!; - close(); - if (value === el.dataset.value) return; - set(value); - onPick(value); - }; - - trigger.addEventListener('click', () => (isOpen() ? close() : open())); - trigger.addEventListener('keydown', (e) => { - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); - open(); - } - }); - menu.addEventListener('keydown', (e) => { - switch (e.key) { - case 'ArrowDown': setActive(active + 1); break; - case 'ArrowUp': setActive(active - 1); break; - case 'Home': setActive(0); break; - case 'End': setActive(options.length - 1); break; - case 'PageDown': setActive(active + 8); break; - case 'PageUp': setActive(active - 8); break; - case 'Enter': - case ' ': choose(active); break; - case 'Escape': close(); break; - // Tab leaves the menu closed, focus back on the trigger it belongs to. - case 'Tab': close(); break; - default: return; - } - e.preventDefault(); - }); - menu.addEventListener('click', (e) => { - const option = (e.target as HTMLElement).closest('[role="option"]'); - if (option) choose(options.indexOf(option)); - }); - menu.addEventListener('pointermove', (e) => { - const option = (e.target as HTMLElement).closest('[role="option"]'); - if (option && options.indexOf(option) !== active) setActive(options.indexOf(option)); - }); - document.addEventListener('pointerdown', (e) => { - if (isOpen() && !el.contains(e.target as Node)) close(false); - }); - menu.addEventListener('focusout', (e) => { - if (isOpen() && !el.contains(e.relatedTarget as Node | null)) close(false); - }); - - return { - get value() { - return el.dataset.value ?? ''; - }, - set, - }; -} - function storage(): Storage | null { try { return window.localStorage; @@ -171,12 +80,23 @@ function storage(): Storage | null { export async function mountPlayground(root: HTMLElement) { const t: Copy = JSON.parse(root.dataset.copy ?? '{}'); - const select = mountPicker(root.querySelector('[data-example]')!, (value) => pick(value)); + const picker = root.querySelector('[data-example]')!; + const select = mountSelect(picker); + picker.addEventListener('change', () => pick(select.value)); const status = root.querySelector('[data-status]')!; const log = root.querySelector('[data-log]')!; const frame = root.querySelector('[data-frame]'); // The app's appearance follows the page's theme until changed here. - const dark = root.querySelector('[data-dark]')!; + // A toggle (`aria-pressed`): pressed is dark. + const darkToggle = root.querySelector('[data-dark]')!; + const dark = { + get checked() { + return darkToggle.getAttribute('aria-pressed') === 'true'; + }, + set checked(on: boolean) { + darkToggle.setAttribute('aria-pressed', String(on)); + }, + }; dark.checked = siteTheme() === 'dark'; onThemeChange((theme) => { dark.checked = theme === 'dark'; @@ -283,7 +203,8 @@ export async function mountPlayground(root: HTMLElement) { setStatus(link); } }); - dark.addEventListener('change', () => { + darkToggle.addEventListener('click', () => { + dark.checked = !dark.checked; frame?.contentWindow?.postMessage({ theme: dark.checked ? 'dark' : 'light' }, '*'); }); diff --git a/third-party/nsh/website/src/lib/remark-code-tabs.js b/third-party/nsh/website/src/lib/remark-code-tabs.js index d407ee34e4..fba6ff3d5a 100644 --- a/third-party/nsh/website/src/lib/remark-code-tabs.js +++ b/third-party/nsh/website/src/lib/remark-code-tabs.js @@ -96,6 +96,7 @@ function group(blocks, number, listLabel) { role: 'tab', id: tabId, className: ['code-tabs__tab'], + dataSlot: 'tabs-trigger', ariaControls: panelId, ariaSelected: selected ? 'true' : 'false', tabIndex: selected ? 0 : -1, @@ -126,9 +127,15 @@ function group(blocks, number, listLabel) { data: { hName: 'div', hProperties: { className: ['code-tabs'], dataCodeTabs: '' } }, children: [ { - type: 'codeTabList', - data: { hName: 'div', hProperties: { role: 'tablist', className: ['code-tabs__list'], ariaLabel: listLabel } }, - children: tabs, + type: 'codeTabHead', + data: { hName: 'div', hProperties: { className: ['code-tabs__head'] } }, + children: [ + { + type: 'codeTabList', + data: { hName: 'div', hProperties: { role: 'tablist', className: ['code-tabs__list'], dataSlot: 'tabs-list', dataSize: 'xs', ariaLabel: listLabel } }, + children: tabs, + }, + ], }, ...panels, ], diff --git a/third-party/nsh/website/src/lib/sections.ts b/third-party/nsh/website/src/lib/sections.ts index e328594d6a..09a0799a88 100644 --- a/third-party/nsh/website/src/lib/sections.ts +++ b/third-party/nsh/website/src/lib/sections.ts @@ -41,7 +41,8 @@ export const SECTIONS: Section[] = [ * Sidebar groups, by the `group` frontmatter key. A section's pages are * sorted by `order`, so a group is a run of consecutive pages; the Guide * uses them to give every platform its own entry next to the others. The - * Elements' groups list their pages by name, in the order of this table. + * Elements' groups follow the order of this table: the primitives by + * `order`, from the simplest, the base components by name. */ export const GROUPS: Record> = { start: { en: 'Start here', 'zh-CN': '入门' }, @@ -91,22 +92,24 @@ async function load(lang: Lang): Promise { // A Chinese page sorts by its English counterpart, so both locales list // the same pages in the same order whatever the translated titles are. const english = lang === 'en' ? entries : await getCollection(collectionName(section.id, 'en')); - // Elements list each group's pages by name (the primitives, then the - // base components); the other sections by `order`. + // Elements list their groups in the order of `GROUPS` (the primitives, + // then the base components); within a group, and in the other sections, + // pages sort by `order`, then by title. Base components have no `order`, + // so they list by name (`tests/elements.test.ts`). const groups = Object.keys(GROUPS); const key = (entry: CollectionEntry) => { const source = english.find((e) => e.id === entry.id) ?? entry; - const order = - section.id === 'elements' && source.data.group - ? 1000 + groups.indexOf(source.data.group as string) - : (source.data.order ?? 999); - return { order, title: source.data.title as string }; + return { + group: section.id === 'elements' ? groups.indexOf(source.data.group) : 0, + order: source.data.order ?? 999, + title: source.data.title as string, + }; }; const sorted = [...entries].sort((a, b) => { if (a.id === 'index' || b.id === 'index') return a.id === 'index' ? -1 : 1; const ka = key(a); const kb = key(b); - return ka.order - kb.order || ka.title.localeCompare(kb.title, 'en', { sensitivity: 'base' }); + return ka.group - kb.group || ka.order - kb.order || ka.title.localeCompare(kb.title, 'en', { sensitivity: 'base' }); }); for (const entry of sorted) { const route = routeOf(section.id, entry.id, lang); diff --git a/third-party/nsh/website/src/lib/showcase-browser.ts b/third-party/nsh/website/src/lib/showcase-browser.ts new file mode 100644 index 0000000000..d070c1e12e --- /dev/null +++ b/third-party/nsh/website/src/lib/showcase-browser.ts @@ -0,0 +1,58 @@ +// App Stories' browsing: search, category, sort and pages. The page's script +// and the tests share it; nothing here changes the editorial Featured order. + +/** What browsing reads of an app. */ +export interface BrowsedApp { + id: string; + name: string; + author?: string; + description: string; + category: string; + platforms: string[]; + source?: string | null; + featured: boolean; + stars: number | null; + publishedAt: string | null; +} + +export type ShowcaseSort = 'newest' | 'stars'; + +export interface ShowcaseQuery { + query?: string; + category?: string; + sort?: ShowcaseSort; +} + +/** The apps a search and category match: Featured in editorial order, the others and all of them sorted. */ +export function selectShowcases(apps: T[], { query = '', category = 'all', sort = 'newest' }: ShowcaseQuery = {}) { + const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + const matches = apps.filter((app) => { + const text = [app.name, app.author ?? '', app.id, app.description, ...app.platforms, app.source ?? ''].join(' ').toLocaleLowerCase(); + return (category === 'all' || app.category === category) && terms.every((term) => text.includes(term)); + }); + const compare = (a: T, b: T) => { + if (sort === 'stars' && a.stars !== b.stars) return (b.stars ?? -1) - (a.stars ?? -1); + return (Date.parse(b.publishedAt ?? '') || 0) - (Date.parse(a.publishedAt ?? '') || 0) || a.name.localeCompare(b.name); + }; + return { + featured: matches.filter((app) => app.featured), + community: matches.filter((app) => !app.featured).sort(compare), + all: [...matches].sort(compare), + }; +} + +/** Six editorial selections per page. */ +export function paginateFeatured(apps: T[], requestedPage = 1) { + return paginate(apps.filter((app) => app.featured), requestedPage, 6); +} + +/** Nine filtered and sorted catalog entries per page. */ +export function paginateAll(apps: T[], requestedPage = 1) { + return paginate(apps, requestedPage, 9); +} + +function paginate(apps: T[], requestedPage: number, pageSize: number) { + const totalPages = Math.ceil(apps.length / pageSize); + const page = Math.max(1, Math.min(requestedPage, totalPages)); + return { apps: apps.slice((page - 1) * pageSize, page * pageSize), page, totalPages, total: apps.length }; +} diff --git a/third-party/nsh/website/src/lib/showcases.ts b/third-party/nsh/website/src/lib/showcases.ts new file mode 100644 index 0000000000..32111db66b --- /dev/null +++ b/third-party/nsh/website/src/lib/showcases.ts @@ -0,0 +1,136 @@ +// App Stories' data: the reviewed catalog of `longbridge/nsh-showcases`, +// checked out next to this repository (`../nsh-showcases`) or at +// `SHOWCASES_DIR`. The catalog's own scripts validate it and render its +// READMEs; this module reads the manifests and the Featured order. The site +// serves each app's media from its own build (`pages/apps/[id]/[file].ts`). +import { existsSync } from 'node:fs'; +import { readFile, readdir, realpath } from 'node:fs/promises'; +import { join, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export interface ShowcaseApp { + id: string; + name: string; + author: string; + category: string; + platforms: string[]; + website: string | null; + source: string | null; + description: string; + building: boolean; + featured: boolean; + publishedAt: string | null; + stars: number | null; + starsUpdatedAt: string | null; + /** Screenshot file names in the app's folder, the cover first. */ + previews: string[]; + /** Every media file of the app's folder, which the site serves. */ + media: string[]; + hasReadme: boolean; +} + +const MEDIA = /^[a-z0-9]+(?:[._-][a-z0-9]+)*\.(png|mp4|webm)$/i; + +/** Reads the catalog at `root`: apps in Featured order, then by ID. */ +export async function loadShowcases(root: string): Promise { + root = resolve(root); + const apps: ShowcaseApp[] = []; + const entries = await readdir(join(root, 'apps'), { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const id = entry.name; + const fail = (message: string): never => { + throw new Error(`Showcase ${id}: ${message}`); + }; + if (!entry.isDirectory()) fail('unexpected entry'); + const dir = join(root, 'apps', id); + const realDir = await realpath(dir); + const manifest = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8')); + const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) || manifest.id !== id) fail('invalid app ID'); + if (!text(manifest.name) || !text(manifest.author) || !text(manifest.description) || !text(manifest.category)) fail('name, author, description and category are required'); + if (!Array.isArray(manifest.platforms) || !manifest.platforms.length || !manifest.platforms.every(text)) fail('missing platforms'); + if (Object.hasOwn(manifest, 'featured')) fail('featured belongs in root featured.json, not the manifest'); + for (const link of [manifest.website, manifest.source]) { + if (link == null) continue; + let protocol = ''; + try { + protocol = new URL(link).protocol; + } catch {} + if (!['https:', 'http:'].includes(protocol)) fail('invalid project URL'); + } + if (!manifest.website && !manifest.source) fail('a project link is required'); + if (!Array.isArray(manifest.previews) || !manifest.previews.length) fail('at least one preview is required'); + const files = await readdir(dir); + const media = files.filter((name) => MEDIA.test(name)).sort(); + for (const name of [...manifest.previews, ...media]) { + if (typeof name !== 'string' || !MEDIA.test(name)) fail(`invalid media file name: ${name}`); + if (!files.includes(name)) fail(`missing ${name}`); + if (!(await realpath(join(dir, name))).startsWith(realDir + sep)) fail(`${name} must stay inside the app folder`); + } + apps.push({ + id, + name: manifest.name, + author: manifest.author, + category: manifest.category, + platforms: manifest.platforms, + website: manifest.website ?? null, + source: manifest.source ?? null, + description: manifest.description, + building: manifest.building === true, + featured: false, + publishedAt: manifest.publishedAt ?? null, + stars: Number.isInteger(manifest.stars) ? manifest.stars : null, + starsUpdatedAt: manifest.starsUpdatedAt ?? null, + previews: manifest.previews, + media, + hasReadme: files.includes('README.md'), + }); + } + const order = JSON.parse(await readFile(join(root, 'featured.json'), 'utf8')); + if (!Array.isArray(order) || new Set(order).size !== order.length || order.some((id) => !apps.some((app) => app.id === id))) { + throw new Error('Invalid Showcase featured.json'); + } + const rank = (id: string) => (order.includes(id) ? order.indexOf(id) : order.length); + for (const app of apps) app.featured = order.includes(app.id); + return apps.sort((a, b) => rank(a.id) - rank(b.id)); +} + +/** The catalog checkout: `SHOWCASES_DIR`, or `nsh-showcases` next to this repository. */ +export const showcaseRoot = () => resolve(process.env.SHOWCASES_DIR ?? resolve(process.cwd(), '../../nsh-showcases')); + +// The catalog's checked-in scripts belong to the same reviewed catalog; they +// run with its locked dependencies (`bun install --frozen-lockfile` there). +async function catalogModule(name: string) { + return import(/* @vite-ignore */ pathToFileURL(join(showcaseRoot(), 'scripts', name)).href); +} + +let catalog: Promise | undefined; + +/** + * The validated catalog. Without a checkout the site builds with no apps, + * unless `NSH_REQUIRE_SHOWCASES` is set (the published site's build). + */ +export function getShowcaseApps(): Promise { + return (catalog ??= (async () => { + const root = showcaseRoot(); + if (!existsSync(join(root, 'apps'))) { + if (process.env.NSH_REQUIRE_SHOWCASES) throw new Error(`No Showcase catalog at ${root}: set SHOWCASES_DIR`); + return []; + } + const { validateCatalog } = await catalogModule('validate.ts'); + await validateCatalog(root); + return loadShowcases(root); + })()); +} + +/** The app's README as HTML, its media below `mediaBase`. */ +export async function getShowcaseReadme(app: ShowcaseApp, mediaBase: string): Promise { + const { renderReadme } = await catalogModule('readme.ts'); + const markdown = await readFile(join(showcaseRoot(), 'apps', app.id, 'README.md'), 'utf8'); + return renderReadme(markdown, app, mediaBase); +} + +/** An app media file's bytes. */ +export function readShowcaseMedia(id: string, name: string): Promise { + return readFile(join(showcaseRoot(), 'apps', id, name)); +} diff --git a/third-party/nsh/website/src/lib/site.ts b/third-party/nsh/website/src/lib/site.ts index 316f5bd886..688ad9ad7a 100644 --- a/third-party/nsh/website/src/lib/site.ts +++ b/third-party/nsh/website/src/lib/site.ts @@ -8,6 +8,9 @@ export const SITE_DESCRIPTION = export const REPO_URL = 'https://github.com/longbridge/nsh'; +/** The App Stories catalog, where apps are submitted. */ +export const SHOWCASES_URL = 'https://github.com/longbridge/nsh-showcases'; + export const GPUI_KIT_URL = 'https://gpui-kit.com'; /** Where "Edit this page" points: the file's path relative to the repository root follows. */ diff --git a/third-party/nsh/website/src/lib/ui/select.ts b/third-party/nsh/website/src/lib/ui/select.ts new file mode 100644 index 0000000000..b9feb2d26a --- /dev/null +++ b/third-party/nsh/website/src/lib/ui/select.ts @@ -0,0 +1,133 @@ +// Drives `components/ui/Select.astro` (WAI-ARIA select-only combobox, as +// shadcn/ui's Select through Radix): the trigger opens the listbox and +// focus moves into it; arrows, Home / End, Page Up / Down and typing a +// label's first letters move the highlight (`data-highlighted`, named by +// `aria-activedescendant`); Enter or Space picks, Escape and Tab close. +// The root keeps the value in `data-value` and fires `change` when the +// reader picks another. + +export interface SelectApi { + readonly value: string; + /** Selects `value` without firing `change` (a page restoring its state). */ + set(value: string): void; +} + +const mounted = new WeakMap(); + +export function mountSelect(root: HTMLElement): SelectApi { + const existing = mounted.get(root); + if (existing) return existing; + const trigger = root.querySelector('[data-slot="select-trigger"]')!; + const content = root.querySelector('[data-slot="select-content"]')!; + const current = root.querySelector('[data-slot="select-value"]')!; + const items = [...content.querySelectorAll('[data-slot="select-item"]')]; + const labelOf = (item: HTMLElement) => item.querySelector('[data-slot="select-item-text"]')?.textContent ?? ''; + let highlighted = 0; + let typed = ''; + let typedAt = 0; + + const selectedIndex = () => Math.max(0, items.findIndex((item) => item.dataset.value === root.dataset.value)); + const highlight = (i: number) => { + highlighted = Math.max(0, Math.min(items.length - 1, i)); + items.forEach((item, k) => item.toggleAttribute('data-highlighted', k === highlighted)); + content.setAttribute('aria-activedescendant', items[highlighted].id); + items[highlighted].scrollIntoView({ block: 'nearest' }); + }; + const isOpen = () => !content.hidden; + const open = () => { + content.hidden = false; + // Below the trigger, or above it when the viewport has no room below. + delete content.dataset.side; + const box = trigger.getBoundingClientRect(); + const height = content.offsetHeight; + if (box.bottom + height + 8 > window.innerHeight && box.top - height - 8 > 0) content.dataset.side = 'top'; + trigger.setAttribute('aria-expanded', 'true'); + highlight(selectedIndex()); + content.focus({ preventScroll: true }); + }; + const close = (refocus = true) => { + if (!isOpen()) return; + content.hidden = true; + trigger.setAttribute('aria-expanded', 'false'); + if (refocus) trigger.focus(); + }; + const set = (value: string) => { + const item = items.find((candidate) => candidate.dataset.value === value); + if (!item) return; + root.dataset.value = value; + for (const candidate of items) candidate.setAttribute('aria-selected', String(candidate === item)); + current.textContent = labelOf(item); + }; + const choose = (i: number) => { + const value = items[i].dataset.value!; + close(); + if (value === root.dataset.value) return; + set(value); + root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value } })); + }; + const typeahead = (key: string) => { + const now = performance.now(); + typed = now - typedAt > 600 ? key : typed + key; + typedAt = now; + const needle = typed.toLowerCase(); + const start = typed.length === 1 ? highlighted + 1 : highlighted; + for (let k = 0; k < items.length; k++) { + const i = (start + k) % items.length; + if (labelOf(items[i]).toLowerCase().startsWith(needle)) return highlight(i); + } + }; + + trigger.addEventListener('click', () => (isOpen() ? close() : open())); + trigger.addEventListener('keydown', (e) => { + if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) { + e.preventDefault(); + open(); + } + }); + content.addEventListener('keydown', (e) => { + switch (e.key) { + case 'ArrowDown': highlight(highlighted + 1); break; + case 'ArrowUp': highlight(highlighted - 1); break; + case 'Home': highlight(0); break; + case 'End': highlight(items.length - 1); break; + case 'PageDown': highlight(highlighted + 8); break; + case 'PageUp': highlight(highlighted - 8); break; + case 'Enter': + case ' ': choose(highlighted); break; + case 'Escape': close(); break; + case 'Tab': close(); break; + default: + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) typeahead(e.key); + else return; + } + e.preventDefault(); + }); + content.addEventListener('click', (e) => { + const item = (e.target as HTMLElement).closest('[data-slot="select-item"]'); + if (item) choose(items.indexOf(item)); + }); + content.addEventListener('pointermove', (e) => { + const item = (e.target as HTMLElement).closest('[data-slot="select-item"]'); + if (item && items.indexOf(item) !== highlighted) highlight(items.indexOf(item)); + }); + document.addEventListener('pointerdown', (e) => { + if (isOpen() && !root.contains(e.target as Node)) close(false); + }); + content.addEventListener('focusout', (e) => { + if (isOpen() && !root.contains(e.relatedTarget as Node | null)) close(false); + }); + + const api: SelectApi = { + get value() { + return root.dataset.value ?? ''; + }, + set, + }; + mounted.set(root, api); + return api; +} + +/** Mounts every Select under `scope`. */ +export function initSelects(scope: ParentNode = document) { + for (const root of scope.querySelectorAll('[data-slot="select"]')) mountSelect(root); +} diff --git a/third-party/nsh/website/src/pages/404.astro b/third-party/nsh/website/src/pages/404.astro index f7cad7c830..5950a58337 100644 --- a/third-party/nsh/website/src/pages/404.astro +++ b/third-party/nsh/website/src/pages/404.astro @@ -2,6 +2,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'; import Nav from '../components/Nav.astro'; import SiteFooter from '../components/SiteFooter.astro'; +import Button from '../components/ui/Button.astro'; import { url } from '../lib/site'; --- @@ -12,8 +13,8 @@ import { url } from '../lib/site';

This page does not exist.

页面不存在。

- Go home - Read the guide + +
@@ -46,8 +47,4 @@ import { url } from '../lib/site'; h1 { margin: 0; font-size: clamp(1.8rem, 4vw, 2.6rem); font-weight: 680; letter-spacing: -0.04em; } .not-found__zh { margin: 0; color: var(--muted-foreground); } .not-found__actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.6rem; margin-top: 1rem; } - .not-found__btn { display: inline-flex; align-items: center; min-height: 2.5rem; padding: 0 1rem; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--finish-raised-bg); box-shadow: var(--finish-raised); color: var(--foreground); font-size: 0.875rem; font-weight: 600; text-decoration: none; } - .not-found__btn:hover { border-color: var(--border-hover); } - .not-found__btn--primary { border-color: transparent; background: var(--finish-primary-bg); box-shadow: var(--finish-primary); color: var(--brand-contrast); } - .not-found__btn--primary:hover { border-color: transparent; background: var(--finish-primary-hover-bg); } diff --git a/third-party/nsh/website/src/pages/apps.astro b/third-party/nsh/website/src/pages/apps.astro new file mode 100644 index 0000000000..0378f652ce --- /dev/null +++ b/third-party/nsh/website/src/pages/apps.astro @@ -0,0 +1,23 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import Nav from '../components/Nav.astro'; +import Apps from '../components/Apps.astro'; +import SiteFooter from '../components/SiteFooter.astro'; +import { getShowcaseApps } from '../lib/showcases'; +import { url } from '../lib/site'; + +const apps = await getShowcaseApps(); +--- + + +