diff --git a/Cargo.lock b/Cargo.lock index 3b55fb526e..4229815ecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3002,6 +3002,7 @@ checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", + "libc", "objc2 0.6.5", ] @@ -3377,7 +3378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6079,7 +6080,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -7751,6 +7752,7 @@ dependencies = [ "maka-workbench", "nsh-engine", "nsh-platform-gpui", + "nsh-platform-macos", "serde", "serde_json", "tokio", @@ -9586,6 +9588,31 @@ dependencies = [ "zbus", ] +[[package]] +name = "nsh-platform-macos" +version = "0.3.0" +dependencies = [ + "block2 0.6.2", + "crossbeam-channel", + "dispatch2", + "libc", + "log", + "nsh-base", + "nsh-core", + "nsh-engine", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-image-io", + "objc2-io-kit", + "objc2-quartz-core 0.3.2", + "rustc-hash 2.1.3", + "serde_json", +] + [[package]] name = "nsh-platform-tui" version = "0.3.0" @@ -9632,7 +9659,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10027,6 +10054,26 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-image-io" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" +dependencies = [ + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -10302,7 +10349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -11900,7 +11947,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -13275,7 +13322,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -13355,7 +13402,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -14890,7 +14937,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -16749,7 +16796,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -17429,7 +17476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ad3cc614cf..cd4ba81846 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ nsh-js = { path = "third-party/nsh/crates/nsh-js" } nsh-platform-tui = { path = "third-party/nsh/crates/nsh-platform-tui" } nsh-platform-gpui = { path = "third-party/nsh/crates/gpui/nsh-platform-gpui", default-features = false, features = ["js"] } nsh-gpui-terminal = { path = "third-party/nsh/crates/gpui/nsh-gpui-terminal" } +nsh-platform-macos = { path = "third-party/nsh/crates/nsh-platform-macos" } nsh-bundler = { path = "third-party/nsh/crates/nsh-bundler", default-features = false } nsh-base = { path = "third-party/nsh/crates/nsh-base" } maka-ui-build = { path = "tools/ui-build" } diff --git a/products/maka/cli/DEPENDENCIES.rust.tsv b/products/maka/cli/DEPENDENCIES.rust.tsv index 03e541a02b..f26a154447 100644 --- a/products/maka/cli/DEPENDENCIES.rust.tsv +++ b/products/maka/cli/DEPENDENCIES.rust.tsv @@ -693,6 +693,7 @@ nsh-js@0.3.0 X nsh-macros@0.3.0 X nsh-package@0.3.0 X nsh-platform-gpui@0.3.0 X +nsh-platform-macos@0.3.0 X nsh-platform-tui@0.3.0 X ntapi@0.4.3 X X nu-ansi-term@0.50.3 X @@ -723,6 +724,8 @@ objc2-core-video@0.3.2 X X X objc2-encode@4.1.0 X objc2-foundation@0.2.2 X objc2-foundation@0.3.2 X +objc2-image-io@0.3.2 X X X +objc2-io-kit@0.3.2 X X X objc2-metal@0.3.2 X X X objc2-quartz-core@0.2.2 X objc2-quartz-core@0.3.2 X X X @@ -1232,7 +1235,6 @@ windows-collections@0.3.2 X X windows-core@0.57.0 X X windows-core@0.58.0 X X windows-core@0.59.0 X X -windows-core@0.61.2 X X windows-core@0.62.2 X X windows-future@0.3.2 X X windows-implement@0.57.0 X X @@ -1253,7 +1255,6 @@ windows-result@0.3.4 X X windows-result@0.4.1 X X windows-strings@0.1.0 X X windows-strings@0.3.1 X X -windows-strings@0.4.2 X X windows-strings@0.5.1 X X windows-sys@0.48.0 X X windows-sys@0.59.0 X X diff --git a/products/maka/cli/THIRD_PARTY_NOTICES.txt b/products/maka/cli/THIRD_PARTY_NOTICES.txt index 183645a118..32205ed19c 100644 --- a/products/maka/cli/THIRD_PARTY_NOTICES.txt +++ b/products/maka/cli/THIRD_PARTY_NOTICES.txt @@ -7755,6 +7755,8 @@ objc2-core-text@0.3.2 (Zlib OR Apache-2.0 OR MIT) objc2-core-video@0.3.2 (Zlib OR Apache-2.0 OR MIT) objc2-encode@4.1.0 (MIT); authors declared by package: Mads Marquart objc2-foundation@0.3.2 (MIT) +objc2-image-io@0.3.2 (Zlib OR Apache-2.0 OR MIT) +objc2-io-kit@0.3.2 (Zlib OR Apache-2.0 OR MIT) objc2-io-surface@0.3.2 (Zlib OR Apache-2.0 OR MIT) objc2-metal@0.3.2 (Zlib OR Apache-2.0 OR MIT) objc2-quartz-core@0.3.2 (Zlib OR Apache-2.0 OR MIT) @@ -27284,6 +27286,7 @@ nsh-gpui-terminal@0.3.0 (Apache-2.0) nsh-macros@0.3.0 (Apache-2.0) nsh-package@0.3.0 (Apache-2.0) nsh-platform-gpui@0.3.0 (Apache-2.0) +nsh-platform-macos@0.3.0 (Apache-2.0) nsh-platform-tui@0.3.0 (Apache-2.0) https://raw.githubusercontent.com/M4n5ter/nsh/de176695240d550f2b303643b8019146c7989e7f/LICENSE-APACHE diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json index 84195055ec..f067502683 100644 --- a/products/maka/cli/notices.json +++ b/products/maka/cli/notices.json @@ -1,7 +1,7 @@ { "inputs": { - "Cargo.lock": "0c4df4b7fdb6ab8ab15ee24e083bb1cab4f41f74daa9445ef5416b204ed81401", - "Cargo.toml": "0ac2ccacded4494795f64c9fb00ab70eee642fddc059293beae570c6aa2eb531", + "Cargo.lock": "ca5a7b90dfd8670beabdcd13809e1f19fd2835b3d4b4e001701c77e3a80618c2", + "Cargo.toml": "daf18767edc88e0f704553cf594f8fd222fbf8edab5118ade55a208b05d532d1", "package-lock.json": "863e20d94f46507befd14d7abe94982bb110dff3efaa13b9d1c3b60333f8d64a", "package.json": "a513ba49c73a1cd6d8060d9a4f2edb47f5286469553cf3b3f4959b9ceb64fa58", "packages/plugin-sdk/src/composer-view.ts": "67a3873beb21778ddbba561f949ebe840c6af33fa0cdb3f3a9da8f6fb4cb03d3", @@ -120,7 +120,7 @@ "products/maka/cli/build/main.rs": "446a3f9fb976bdf8ee1f0f21f2a5dfce0c0854b32f1a1c0294143cb654171e7f", "products/maka/cli/build/product.rs": "0ff203a6c9b04f97ee75a3ac55af384865fe3e74d5c169d5a022a9de8527584d", "products/maka/client/Cargo.toml": "33a6ff74d79ce70081336cad1f552f7705d0bf10ad82c341f3c0739332c0503c", - "products/maka/desktop-shell/Cargo.toml": "d8c2fb2206dc4c4afddea1f38e0cd067f09008147f6cd56bf86726395b96ea86", + "products/maka/desktop-shell/Cargo.toml": "ef2a796c49d8fa974c776965f5f7d6580f3758bd4b519e4394492b25199cb61b", "products/maka/native/Cargo.toml": "f174300d1f9cb5a8c683e2c83514092be65556abfff12757b6b315cc06490cca", "products/maka/terminal-shell/Cargo.toml": "7735e5db9dedb2560f5a13e53ee085611f48a7145ed179b22df982ab44831841", "products/maka/workbench/Cargo.toml": "3328f5e00d8748bf27142eacb92780354af10160b807f20513d0fc1690a6caec", @@ -534,7 +534,7 @@ "third-party/nsh/Cargo.toml": "25b8c6dc87977b07a422fd87d2640a2c250fd6aae6b533d0b4a6c28f2a8e82fb", "third-party/nsh/LICENSE-APACHE": "ec75427ad955e3c740f22e7dc3f6db6fc5b2cc1d9135c2af91cece8dd47a2d38", "third-party/nsh/README.md": "7e96dcaff053bd42b852ac90e14c2d2deed5e607630f5697284106de14c386d1", - "third-party/nsh/SOURCE.md": "8f9b622108c3b1539ad5977ddbc832d0788b3731388a5b82a2c97e5b46be2dc5", + "third-party/nsh/SOURCE.md": "75b39aa8662963da027f9dcee960a5a4e8b77503f79158c1cd51350a1fcdd310", "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", @@ -925,7 +925,7 @@ "third-party/nsh/crates/nsh-cli/src/templates/tsconfig/5.json": "5d6ee953f0366848a5cbe9fe9d0a6b31637d14e8dfb20761e7730c9228753b1a", "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": "8ca3cc5d23d12ba63db4edc5293d92244b5e50a37c904d7815fc06e9df731661", + "third-party/nsh/crates/nsh-cli/src/test_host.rs": "a9a8260ffaf68f7516489c3024d4d476e578a7f782549c26202e9d4f91fdb3a8", "third-party/nsh/crates/nsh-cli/src/test_ios.rs": "001f75d6f862a31d2352e09c51317f100ae6ddab8d983761315c25760b258f01", "third-party/nsh/crates/nsh-cli/src/test_web.rs": "b657afd25fd7f33f734cd8ac597bb580a0f341a0453828b1d73f3b71b74436bd", "third-party/nsh/crates/nsh-cli/src/tui.rs": "edadbdd4996dce76cf87b4e8eb4e4101fa6ed338f2ebb7f0725a6c61d2bde6c0", @@ -1456,21 +1456,21 @@ "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": "764f146a6468c50df0297cbdc4bb54154ceb1025f62c85883682e45b4eed7fe2", + "third-party/nsh/crates/nsh-platform-macos/src/app.rs": "c0d6795f7e81ec5f9de0710c8e6d7ba8accb615c8f8ea04791998675e4ffc042", "third-party/nsh/crates/nsh-platform-macos/src/blur.rs": "04af7f839f5e5a9d7005638c836b748c2364c804e45174a22d8c8a1e3d7945d1", "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/chrome.rs": "cbc9790b5877da6462306f564b2d849fa3d9e3d19f63b65ad9e7fc213d120951", "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/file_requests.rs": "b8fad6bf511f9375785f71ad030701d45eec44ea8437a10977c97c1de7a8e4fe", "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": "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": "98b697238d9b0a20448d3956bc4688365c3570f392bb26aeea08e170cca547e3", - "third-party/nsh/crates/nsh-platform-macos/src/mount.rs": "16bf70de75889bfee722eba15f4dfebc725c857be22c8090d767f295cf90922e", + "third-party/nsh/crates/nsh-platform-macos/src/image.rs": "86383ab7fe14c816b170d24fd5af1b4c725d41f2449ec35493b5c250ec5338a0", + "third-party/nsh/crates/nsh-platform-macos/src/input.rs": "cce4450d2765c368dca4f0822f29621d4e764c5adf492089565e78c3e5dfde82", + "third-party/nsh/crates/nsh-platform-macos/src/keymap.rs": "927d899967b30232c9e8264d31687f33e4ce22f71361839db4a9132a6572d058", + "third-party/nsh/crates/nsh-platform-macos/src/lib.rs": "927295694b6964a2c0f800b2d5e711e230f9308c956e1bc7599ae03a4c642c1c", + "third-party/nsh/crates/nsh-platform-macos/src/mount.rs": "2f9c1854836bf58059c427a0754c6338173f0c8359e3cf8f93af98ebf20990ac", "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", @@ -1479,14 +1479,16 @@ "third-party/nsh/crates/nsh-platform-macos/src/scroll.rs": "1e0c30b571c234602515f61ee94aa23f87eae6e8375d8116d19cd04cd3e4705d", "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": "2cfe5ed536054a3d4c2fa877b8b1ad2635273b7b5e11f87284bd8533436e0eef", + "third-party/nsh/crates/nsh-platform-macos/src/surface.rs": "27e0818c21b2c6f0a5b4f9aa6cec6b9105f36dfaec8645d5174f5a7374042ca0", "third-party/nsh/crates/nsh-platform-macos/src/synth.rs": "29e120eadacb6b62c6de7b66d8a0c6a99d7ed67cb20ca448cc1945f174a28de7", "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/text_input.rs": "0662dcff7db4dcd15de293c6a9daf07031d7444c365ab4f5193e7b77d16c1f64", "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/view.rs": "a495109fcef50ec0cc09b07dd51953506cec3e2db33b4d3f5efc87656307dca2", "third-party/nsh/crates/nsh-platform-macos/src/visual.rs": "6c32d3f286ed8f7fcd0daf45f269f54403e70b773e62ff4c87830575402e6c62", + "third-party/nsh/crates/nsh-platform-macos/tests/keymap/app.tsx": "e188531afe4eb97d85e88916b8d2916af0dd74037bc2198deb6a059195d3fdfe", + "third-party/nsh/crates/nsh-platform-macos/tests/keymap/phases.test.tsx": "6edf6559fcb0d6ac93786dc283ec6e1db3b61bf2b6d6e4ed7a430abafc657fd8", "third-party/nsh/crates/nsh-platform-tui/Cargo.toml": "8757c1cfc5b97419ea8ac3d7502493c123cace9fe61d5998ebd7f7d6d9926e11", "third-party/nsh/crates/nsh-platform-tui/src/app.rs": "fca461bf66e704bbe825088f9b713739f626b30686d487f334253bb6dff82deb", "third-party/nsh/crates/nsh-platform-tui/src/canvas.rs": "f03e24bb5963be16d17d80215f1d54a3e2350a5a75bb1391c945ead828736ad2", @@ -1751,7 +1753,7 @@ "third-party/nsh/docs/dev/js-modules.md": "57504b18df9432fdc0ae2c88ba557a5fcc9a0fa16af7610cdf63ba7bcb994082", "third-party/nsh/docs/dev/layerfs.md": "df9b16b2f5f1e61f9693f63b61438a51461d05b535a0046d50ac9ba1ade12b8b", "third-party/nsh/docs/dev/linux.md": "f9406e66244845c6d1c50234992eb3f069f1c710f8d541f60e60d726d85b646f", - "third-party/nsh/docs/dev/macos.md": "117f5b3f86a8643e9bb56af509d00ef9d2fc31045aef74bc5439f4179f2a6d47", + "third-party/nsh/docs/dev/macos.md": "a4328836d68e1b1c80c83677257541618ea4315fe888a63b20b204e6ac92d3a8", "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", @@ -2930,5 +2932,5 @@ "tools/ui-build/src/lib.rs": "a47a8b18ae4056eb408601bbdcc9b88f971bf62437b3f1e39a64cfb194f19af4", "tools/ui-build/src/main.rs": "9dc0ce6be54d8ed5cf6f40d151fde718822b65a3c36eca7c2b0008d5d9fd5426" }, - "sha256": "c082bc45f0eba2fe583fa31536a7596f84d303715ca9e3c015d49b1c4d4bf91b" + "sha256": "b63e66c871a0e660df55ffa5610b0f941f87c85809ab0f24b5739244602bf184" } diff --git a/products/maka/desktop-shell/Cargo.toml b/products/maka/desktop-shell/Cargo.toml index 3245abd286..bcbde50fc5 100644 --- a/products/maka/desktop-shell/Cargo.toml +++ b/products/maka/desktop-shell/Cargo.toml @@ -39,3 +39,6 @@ embed-resource = "3.0" # Shaders compile at run time, so building needs no Metal Toolchain (macOS). nsh-platform-gpui = { workspace = true, features = ["runtime-shaders"] } gpui_platform = { package = "gpui-fast-platform", version = "0.1.10", default-features = false, features = ["font-kit", "wayland", "x11"] } + +[target.'cfg(target_os = "macos")'.dependencies] +nsh-platform-macos.workspace = true diff --git a/products/maka/desktop-shell/README.md b/products/maka/desktop-shell/README.md index 2da879e9e2..6e1ddc7d90 100644 --- a/products/maka/desktop-shell/README.md +++ b/products/maka/desktop-shell/README.md @@ -51,3 +51,5 @@ Closing the window, the quit menu item and the quit key all close the window; th The window opens on the display, at the position and size, and in the full-screen or maximized state it had when it last closed, as recorded in `clients/desktop-window.json` in the Root. If that display is gone it opens centered on the main display; if too little of its top edge would be on the display to grab, it opens centered there. If the Host stops while the window is open (it crashed, or was retired over its clients), the shell notices within a few seconds, connects to the Root's next Host, starting one through the launcher as `maka desktop` does, and loads the workbench again in the same window. The workbench starts over, as when the desktop opens; a turn the stopped Host was running shows as stopped. Until a Host is reached the window keeps the last workbench, whose actions fail, and the shell retries with growing pauses of up to 30 seconds. It reconnects only while the Root it opened is still at its path: a moved or deleted Root is not initialized afresh. + +On macOS, a non-empty `MAKA_APPKIT` runs the workbench on NSH's AppKit backend instead of GPUI. This backend is experimental and not the default. It saves the window's frame in the application's defaults rather than in `clients/desktop-window.json`, without the full-screen state. If the Host stops, the window keeps the last workbench and does not reconnect. It refuses plugin changes that need a UI reload, and it posts no system notifications. diff --git a/products/maka/desktop-shell/README.zh-CN.md b/products/maka/desktop-shell/README.zh-CN.md index e4e0c4c70c..de05be35ac 100644 --- a/products/maka/desktop-shell/README.zh-CN.md +++ b/products/maka/desktop-shell/README.zh-CN.md @@ -49,3 +49,5 @@ just build 窗口会在上次关闭时所在的显示器上打开,位置、大小以及全屏或最大化状态都与当时相同;这些记录在 Root 的 `clients/desktop-window.json` 里。那个显示器已断开时,窗口在主显示器上居中打开;窗口顶边留在显示器上的部分不够拖动时,窗口在那个显示器上居中打开。 窗口打开期间如果 Host 停止(崩溃,或在仍有客户端时被强制退役),shell 会在几秒内察觉,连接该 Root 的下一个 Host(没有运行中的 Host 时像 `maka desktop` 一样经由启动器启动),并在同一个窗口里重新加载工作台。工作台从头开始,与打开桌面时相同;停止的 Host 正在执行的那一轮显示为已停止。连上下一个 Host 之前,窗口保留原来的工作台,其中的操作都会失败;shell 按逐步拉长的间隔重试,最长 30 秒。只有当它打开的那个 Root 仍在原路径时才会重连:被移走或删除的 Root 不会被重新初始化。 + +在 macOS 上,把 `MAKA_APPKIT` 设为非空值后,工作台改用 NSH 的 AppKit 后端运行,而不是 GPUI。这个后端仍在实验阶段,也不是默认后端。它把窗口框架保存在应用的 defaults 里,而不是 `clients/desktop-window.json`,也不保存全屏状态。Host 停止后,窗口保留原来的工作台,不会重连。需要重载界面的插件改动会被拒绝,也不发送系统通知。 diff --git a/products/maka/desktop-shell/src/appkit.rs b/products/maka/desktop-shell/src/appkit.rs new file mode 100644 index 0000000000..aadc30ea92 --- /dev/null +++ b/products/maka/desktop-shell/src/appkit.rs @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +//! The workbench in a window of NSH's AppKit backend, on macOS, opted into +//! with [`ENABLE`]. It has one Host: the window does not follow a Host that +//! stops, and refuses a UI reload rather than pausing its input for one. + +use crate::{ + host::{self, Connection}, + window::{APP_ID, MIN_SIZE, Requests, SIZE, Shell, TITLE}, +}; +use maka_workbench::reload::{Request, UiReload}; +use nsh_engine::JsonValue; +use nsh_platform_macos::{AppOptions, quit_app, run_app_with_config, with_running_view}; +use tokio::{runtime::Runtime, sync::watch}; + +/// Set and not empty, the shell opens this window instead of the GPUI one. +pub const ENABLE: &str = "MAKA_APPKIT"; + +pub fn run(first: Connection, runtime: Runtime) -> Result<(), String> { + let Connection { + bindings, + config, + directory, + endpoint, + epoch, + } = first; + let (active, foreground) = watch::channel(false); + let (shell, requests) = Shell::new(foreground); + let reloading = config + .context + .get::() + .expect("the admitted workbench owns its UI reload channel") + .take_requests(); + let config = shell.attach(config); + carry_out(requests, &runtime); + refuse_reloads(reloading, &runtime); + let options = AppOptions { + app_id: APP_ID.into(), + title: TITLE.into(), + size: SIZE, + min_size: Some((MIN_SIZE.0 as i32, MIN_SIZE.1 as i32)), + frame_autosave_name: Some(format!("{TITLE} {}", directory.display())), + ..Default::default() + }; + run_app_with_config(config, options, move |is_active| { + active.send_replace(is_active); + }); + runtime.block_on(host::detach(bindings, &endpoint, epoch)); + runtime.shutdown_background(); + Ok(()) +} + +fn carry_out(requests: Requests, runtime: &Runtime) { + let Requests { + mut exits, + mut folders, + mut text_sizes, + mut notices, + } = requests; + runtime.spawn(async move { + if exits.recv().await.is_some() { + quit_app(); + } + }); + runtime.spawn(async move { + while let Some(call) = folders.recv().await { + with_running_view(move |view| { + view.choose_directory(move |path| match path { + Some(path) => match path.to_str() { + Some(path) => call.resolve(JsonValue::from(path)), + None => call.reject("the folder's path is not valid UTF-8"), + }, + None => call.resolve(JsonValue::Null), + }); + }); + } + }); + runtime.spawn(async move { + while let Some(scale) = text_sizes.recv().await { + with_running_view(move |view| view.set_font_scale(Some(scale))); + } + }); + runtime.spawn(async move { + while notices.recv().await.is_some() { + eprintln!("maka-desktop-shell: the AppKit window posts no notifications"); + } + }); +} + +fn refuse_reloads(mut reloading: tokio::sync::mpsc::UnboundedReceiver, runtime: &Runtime) { + runtime.spawn(async move { + while let Some(request) = reloading.recv().await { + if let Request::Pause(ready) = request { + let _ = ready.send(Err("the AppKit window cannot reload its UI".into())); + } + } + }); +} diff --git a/products/maka/desktop-shell/src/host.rs b/products/maka/desktop-shell/src/host.rs index bac2adcfcc..9aa05865ef 100644 --- a/products/maka/desktop-shell/src/host.rs +++ b/products/maka/desktop-shell/src/host.rs @@ -37,6 +37,8 @@ const CHECK_EVERY: Duration = Duration::from_secs(2); const RECHECK_AFTER: Duration = Duration::from_millis(500); /// How long a check waits for the Host to answer. const STATUS_LIMIT: Duration = Duration::from_secs(2); +/// How long quitting waits for the Host connection to close. +pub const DETACH_LIMIT: Duration = Duration::from_secs(5); /// The workbench on one Host: its bindings, the engine configuration made /// from them, the Root's client directory, and the Host they reach. @@ -136,6 +138,19 @@ pub async fn runs(endpoint: &Path, epoch: Uuid) -> bool { } } +/// Closes `bindings` for good, within [`DETACH_LIMIT`]. A failure is reported +/// only while their Host runs: one that stopped has no client left to detach. +pub async fn detach(bindings: Bindings, endpoint: &Path, epoch: Uuid) { + let error = match tokio::time::timeout(DETACH_LIMIT, bindings.close()).await { + Ok(Ok(())) => return, + Ok(Err(error)) => format!("Host cleanup: {error}"), + Err(_) => "Host cleanup did not finish in time".to_owned(), + }; + if runs(endpoint, epoch).await { + eprintln!("maka-desktop-shell: {error}"); + } +} + /// Ends once the Host at `endpoint` has stopped or is stopping: two checks /// in a row could not reach it, found another Host or found it closing. pub async fn stopped(endpoint: PathBuf, epoch: Uuid) { diff --git a/products/maka/desktop-shell/src/main.rs b/products/maka/desktop-shell/src/main.rs index d38b3b914b..e52fa1c688 100644 --- a/products/maka/desktop-shell/src/main.rs +++ b/products/maka/desktop-shell/src/main.rs @@ -27,7 +27,10 @@ //! window connects to the Root's next Host, which the launcher named by //! `MAKA_LAUNCHER` starts, and loads the workbench again. Opened directly, //! from Finder, the Dock or a file manager, it runs `maka desktop` beside it. +//! On macOS, `MAKA_APPKIT` selects NSH's AppKit backend instead (`appkit`). +#[cfg(target_os = "macos")] +mod appkit; #[cfg(any(target_os = "linux", target_os = "macos", windows))] mod host; #[cfg(any(target_os = "linux", target_os = "macos", windows))] @@ -163,6 +166,10 @@ fn run() -> Result<(), String> { let handle = tokio::runtime::Handle::current(); host::Connection::admit(host, target.endpoint, root.path(), handle).await })?; + #[cfg(target_os = "macos")] + if std::env::var_os(appkit::ENABLE).is_some_and(|value| !value.is_empty()) { + return appkit::run(first, runtime); + } window::run(first, root, runtime) } diff --git a/products/maka/desktop-shell/src/window.rs b/products/maka/desktop-shell/src/window.rs index e1d5066741..4d4e160cda 100644 --- a/products/maka/desktop-shell/src/window.rs +++ b/products/maka/desktop-shell/src/window.rs @@ -21,7 +21,7 @@ //! //! Closing the window, the quit key and the quit menu item all close the //! window; once no window is left the client detaches from the Host (its -//! tasks stop and its connection closes, within [`DETACH_LIMIT`]) and then the +//! tasks stop and its connection closes, within [`host::DETACH_LIMIT`]) and then the //! app quits. On macOS quitting ends the process (`terminate:`), so the //! detach runs before it, not after the event loop returns. //! @@ -51,13 +51,11 @@ use std::{ }; use tokio::{runtime::Handle, sync::watch}; -const APP_ID: &str = "org.apache.maka.desktop"; -const TITLE: &str = "Maka"; -const SIZE: (i32, i32) = (1280, 820); +pub(crate) const APP_ID: &str = "org.apache.maka.desktop"; +pub(crate) const TITLE: &str = "Maka"; +pub(crate) const SIZE: (i32, i32) = (1280, 820); /// The narrowest layout the workbench folds its sidebar for. -const MIN_SIZE: (f32, f32) = (640., 480.); -/// How long quitting waits for the Host connection to close. -const DETACH_LIMIT: Duration = Duration::from_secs(5); +pub(crate) const MIN_SIZE: (f32, f32) = (640., 480.); /// The first pause between attempts to reach the Root's next Host. const FIRST_PAUSE: Duration = Duration::from_secs(1); /// The longest pause between attempts to reach the Root's next Host. @@ -78,18 +76,15 @@ pub fn run(first: Connection, root: Root, runtime: tokio::runtime::Runtime) -> R let handle = runtime.handle().clone(); let detach = Detach::new(bindings, (endpoint.clone(), epoch), runtime); let (active, foreground) = watch::channel(false); - let (exit, mut exits) = tokio::sync::mpsc::unbounded_channel(); - let (folder, mut folders) = tokio::sync::mpsc::unbounded_channel::(); - let (text_size, mut text_sizes) = tokio::sync::mpsc::unbounded_channel::(); - let (notify, mut notices) = tokio::sync::mpsc::unbounded_channel::(); - let shell = Shell { - foreground, - exit, - folder, - text_size, - notify, - events: Arc::default(), - }; + let ( + shell, + Requests { + mut exits, + mut folders, + mut text_sizes, + mut notices, + }, + ) = Shell::new(foreground); let mut start = Some((config, Placement::new(&directory), endpoint, epoch, root)); // Quitting waits for the detach: GPUI's default quits Linux and Windows // apps as their last window closes, giving the detach no more than its @@ -405,9 +400,17 @@ fn follow( .detach(); } +/// What the workbench asks of its window, for the window to carry out. +pub(crate) struct Requests { + pub(crate) exits: tokio::sync::mpsc::UnboundedReceiver<()>, + pub(crate) folders: tokio::sync::mpsc::UnboundedReceiver, + pub(crate) text_sizes: tokio::sync::mpsc::UnboundedReceiver, + pub(crate) notices: tokio::sync::mpsc::UnboundedReceiver, +} + /// What the window gives each workbench it loads, beside its Host. #[derive(Clone)] -struct Shell { +pub(crate) struct Shell { /// Whether the window is active. foreground: watch::Receiver, /// Closes the window. @@ -422,7 +425,29 @@ struct Shell { } impl Shell { - fn attach(&self, mut config: EngineConfig) -> EngineConfig { + pub(crate) fn new(foreground: watch::Receiver) -> (Self, Requests) { + let (exit, exits) = tokio::sync::mpsc::unbounded_channel(); + let (folder, folders) = tokio::sync::mpsc::unbounded_channel(); + let (text_size, text_sizes) = tokio::sync::mpsc::unbounded_channel(); + let (notify, notices) = tokio::sync::mpsc::unbounded_channel(); + let shell = Self { + foreground, + exit, + folder, + text_size, + notify, + events: Arc::default(), + }; + let requests = Requests { + exits, + folders, + text_sizes, + notices, + }; + (shell, requests) + } + + pub(crate) fn attach(&self, mut config: EngineConfig) -> EngineConfig { config.context = config .context .with(maka_workbench::Foreground(self.foreground.clone())); @@ -517,18 +542,7 @@ impl Detach { Some(bindings) => { let (endpoint, epoch) = state.host.clone(); state.runtime.spawn(async move { - let error = match tokio::time::timeout(DETACH_LIMIT, bindings.close()).await - { - Ok(Ok(())) => None, - Ok(Err(error)) => Some(format!("Host cleanup: {error}")), - Err(_) => Some("Host cleanup did not finish in time".to_owned()), - }; - // A Host that stopped has no client left to detach. - if let Some(error) = error - && host::runs(&endpoint, epoch).await - { - eprintln!("maka-desktop-shell: {error}"); - } + host::detach(bindings, &endpoint, epoch).await; done.send_replace(true); }); } @@ -539,7 +553,7 @@ impl Detach { let mut moving = state.moving.subscribe(); state.runtime.spawn(async move { let settled = moving.wait_for(|moving| !*moving); - let _ = tokio::time::timeout(DETACH_LIMIT, settled).await; + let _ = tokio::time::timeout(host::DETACH_LIMIT, settled).await; done.send_replace(true); }); } @@ -582,10 +596,10 @@ impl Detach { } /// Closes `bindings` taken from the window, whatever their Host - /// answers, within [`DETACH_LIMIT`]. + /// answers, within [`host::DETACH_LIMIT`]. fn close(&self, bindings: Bindings) -> tokio::task::JoinHandle<()> { self.0.borrow().runtime.spawn(async move { - let _ = tokio::time::timeout(DETACH_LIMIT, bindings.close()).await; + let _ = tokio::time::timeout(host::DETACH_LIMIT, bindings.close()).await; }) } @@ -601,7 +615,7 @@ impl Detach { } /// A move of the window's client to another Host. Quitting waits (within -/// [`DETACH_LIMIT`]) until it is dropped. +/// [`host::DETACH_LIMIT`]) until it is dropped. struct Moving(watch::Sender); impl Drop for Moving { diff --git a/third-party/nsh/SOURCE.md b/third-party/nsh/SOURCE.md index 595deeb27d..d21b26580b 100644 --- a/third-party/nsh/SOURCE.md +++ b/third-party/nsh/SOURCE.md @@ -93,14 +93,20 @@ 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 +GPUI, terminal and AppKit 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 +Generic GPUI and terminal 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. +The AppKit `AppView::load_with_config`, `run_app_with_config`, +`with_running_view`, `AppView::choose_directory`, `AppOptions::min_size` and +`AppOptions::frame_autosave_name` let Maka's desktop shell run its own +`EngineConfig` on an opt-in path. An accessibility press and scoped and fallback +key bindings complete that path; AppKit input has no `tokens`. + 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 diff --git a/third-party/nsh/crates/nsh-cli/src/test_host.rs b/third-party/nsh/crates/nsh-cli/src/test_host.rs index aeaf8e7a46..ea429a29d9 100644 --- a/third-party/nsh/crates/nsh-cli/src/test_host.rs +++ b/third-party/nsh/crates/nsh-cli/src/test_host.rs @@ -92,6 +92,7 @@ pub(crate) fn host_target(target: Target) -> nsh_testing::HostTarget { Target::Linux => ("linux", Platform::Linux, true), Target::Web => ("web", Platform::Web, true), Target::Gpui => ("gpui", desktop, true), + Target::Tui => unreachable!("starter rejects terminal test hosts"), }; let mut host = nsh_testing::HostTarget::default(); host.name = name.to_owned(); diff --git a/third-party/nsh/crates/nsh-platform-macos/src/app.rs b/third-party/nsh/crates/nsh-platform-macos/src/app.rs index efea7dc603..2fd881654e 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/app.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/app.rs @@ -124,6 +124,14 @@ pub struct AppOptions { /// Reopening from the Dock shows the same window. Quit still stops the app. /// Only used by [`run_app`]; defaults to false. pub keep_alive_on_close: bool, + /// The smallest size [`run_app`]'s window takes, in points. `None` (the + /// default) leaves it to AppKit. + pub min_size: Option<(i32, i32)>, + /// The name [`run_app`]'s window keeps its frame under in the user's + /// defaults: it opens where the window with this name last was (AppKit + /// keeps it on a connected display) and keeps it up to date. `None` (the + /// default) centers it every time. + pub frame_autosave_name: Option, /// The native components apps can use (`nsh/native`); shared by the /// views made with these options. See [`AppView::register_component`]. pub components: crate::native::Components, @@ -171,6 +179,8 @@ impl Default for AppOptions { display: None, keep_size: false, keep_alive_on_close: false, + min_size: None, + frame_autosave_name: None, components: Default::default(), appearance: Appearance::System, fps: false, @@ -330,6 +340,7 @@ impl AppView { picker: None, }; crate::image::set_asset_base(&self.surface, options.asset_base.clone()); + *self.surface.image_assets.borrow_mut() = crate::image::AssetSource::Base; *self.surface.chunks.borrow_mut() = options.chunks.clone(); self.surface .native @@ -338,6 +349,24 @@ impl AppView { self.surface.start(config); } + /// Runs an engine the embedding app configured, replacing what ran. The + /// surface supplies its viewport and environment; the rest of `config` is + /// kept. Its asset reader serves both the engine's resources and image + /// `asset:/` URIs (`None` makes those unavailable), bound to this load + /// whatever [`Self::set_asset_base`] says. + pub fn load_with_config(&self, config: EngineConfig) { + *self.surface.image_assets.borrow_mut() = + crate::image::AssetSource::engine(config.assets.clone()); + self.surface.native.set_app(&config.app); + self.surface.start(config); + } + + /// Asks the user for one folder, in a sheet of the view's window: `done` + /// gets its path on the main thread, or `None` when cancelled. + pub fn choose_directory(&self, done: impl FnOnce(Option) + 'static) { + crate::file_requests::choose_directory(&self.surface, done); + } + /// Where `asset:/` URIs resolve from now on. pub fn set_asset_base(&self, base: Option) { self.options.borrow_mut().asset_base = base; @@ -434,6 +463,8 @@ fn initial_environment() -> Environment { idiom: Idiom::Desktop, screen_color: true, file_requests: true, + native_focus_scopes: true, + fallback_key_bindings: true, ..Environment::default() } } @@ -443,6 +474,7 @@ struct Running { window: Retained, view: Rc, keep_alive_on_close: bool, + on_active: Box, } thread_local! { @@ -514,6 +546,16 @@ define_class!( } unsafe impl NSWindowDelegate for Delegate { + #[unsafe(method(windowDidBecomeKey:))] + fn window_did_become_key(&self, _notification: &NSNotification) { + window_active(true); + } + + #[unsafe(method(windowDidResignKey:))] + fn window_did_resign_key(&self, _notification: &NSNotification) { + window_active(false); + } + #[unsafe(method(windowShouldClose:))] fn should_close(&self, window: &NSWindow) -> bool { let keep_alive = RUNNING.with(|running| { @@ -542,6 +584,26 @@ define_class!( } ); +/// Tells [`run_app_with_config`]'s caller whether the window has the keyboard. +fn window_active(active: bool) { + RUNNING.with(|running| { + if let Some(running) = &*running.borrow() { + (running.on_active)(active); + } + }); +} + +/// Runs `f` with the standalone runner's view on AppKit's main thread. May be +/// called from any thread; with no standalone runner `f` does not run. +pub fn with_running_view(f: impl FnOnce(&AppView) + Send + 'static) { + dispatch2::DispatchQueue::main().exec_async(move || { + let view = RUNNING.with(|running| running.borrow().as_ref().map(|r| r.view.clone())); + if let Some(view) = view { + f(&view); + } + }); +} + /// Quits the standalone runner, including a retained hidden window. /// Stops its engine and returns from [`run_app`]. May be called from any thread; /// the request is dispatched to AppKit's main thread. With no standalone runner @@ -627,6 +689,35 @@ fn name_app(name: &str) { /// `keep_alive_on_close` retains the same engine until Quit or [`quit_app`]. /// The caller can finish cleanup or relaunch after this function returns. pub fn run_app(source: AppSource, options: AppOptions) -> std::process::ExitCode { + let load = move |view: &Rc| match source { + AppSource::Rust(root) => view.load(App::Rust(root)), + AppSource::Bundle(bundle) => view.load(App::Js(bundle)), + #[cfg(feature = "dev")] + AppSource::DevServer(url) => crate::dev::start(view, url), + }; + run_window(options, load, |_| {}) +} + +/// [`run_app`] for an engine the embedding app configured +/// ([`AppView::load_with_config`]). `on_active` hears, on the main thread, +/// whether the window has the keyboard, from when it opens until it closes. +pub fn run_app_with_config( + config: EngineConfig, + options: AppOptions, + on_active: impl Fn(bool) + 'static, +) -> std::process::ExitCode { + run_window( + options, + move |view| view.load_with_config(config), + on_active, + ) +} + +fn run_window( + options: AppOptions, + load: impl FnOnce(&Rc), + on_active: impl Fn(bool) + 'static, +) -> std::process::ExitCode { let mtm = MainThreadMarker::new().expect("run_app must be called on the main thread"); let app = NSApplication::sharedApplication(mtm); app.setActivationPolicy(NSApplicationActivationPolicy::Regular); @@ -680,20 +771,25 @@ pub fn run_app(source: AppSource, options: AppOptions) -> std::process::ExitCode if let Some(display) = options.display { center_on_display(&window, display); } - match source { - AppSource::Rust(root) => view.load(App::Rust(root)), - AppSource::Bundle(bundle) => view.load(App::Js(bundle)), - #[cfg(feature = "dev")] - AppSource::DevServer(url) => crate::dev::start(&view, url), + if let Some((width, height)) = options.min_size { + window.setContentMinSize(NSSize::new(width as f64, height as f64)); + } + if let Some(name) = &options.frame_autosave_name { + let name = NSString::from_str(name); + window.setFrameUsingName(&name); + window.setFrameAutosaveName(&name); } + load(&view); window.makeKeyAndOrderFront(None); window.makeFirstResponder(Some(view.surface().root.ns())); app.activate(); + on_active(window.isKeyWindow()); RUNNING.with(|r| { *r.borrow_mut() = Some(Running { window, view, keep_alive_on_close: options.keep_alive_on_close, + on_active: Box::new(on_active), }) }); app.run(); diff --git a/third-party/nsh/crates/nsh-platform-macos/src/chrome.rs b/third-party/nsh/crates/nsh-platform-macos/src/chrome.rs index b7d90a1bd2..3571616617 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/chrome.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/chrome.rs @@ -398,6 +398,7 @@ pub(crate) fn apply_title_bar(surface: &Surface) { return; }; let hidden = surface.chrome.style.get() == TitleBarStyle::Hidden; + let frame = window.frame(); let mut mask = window.styleMask(); if hidden { mask |= NSWindowStyleMask::FullSizeContentView; @@ -412,6 +413,11 @@ pub(crate) fn apply_title_bar(surface: &Surface) { } else { NSWindowTitleVisibility::Visible }); + // AppKit keeps the content's size here, which would shrink a saved frame + // by the title bar on every launch. + if window.frameAutosaveName().length() > 0 && window.frame() != frame { + window.setFrame_display(frame, true); + } update_area(surface); // AppKit can restore the old content size after setStyleMask returns. // Reconcile it after that pass, using the current window and style so diff --git a/third-party/nsh/crates/nsh-platform-macos/src/file_requests.rs b/third-party/nsh/crates/nsh-platform-macos/src/file_requests.rs index a41f0cd1b7..dd9f7e3ea5 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/file_requests.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/file_requests.rs @@ -6,7 +6,7 @@ //! went). The panels answer as a root `FileRequest` event when they close, //! the rest at once. -use std::{path::PathBuf, rc::Rc}; +use std::{cell::Cell, path::PathBuf, rc::Rc}; use block2::RcBlock; use nsh_core::{ @@ -148,6 +148,35 @@ unsafe fn begin(surface: &Surface, panel: &AnyObject, handler: impl Fn(isize) + } } +/// Asks the host's question for one folder: `done` gets its path, or `None` +/// when cancelled. +pub(crate) fn choose_directory(surface: &Surface, done: impl FnOnce(Option) + 'static) { + let Some(class) = AnyClass::get(c"NSOpenPanel") else { + return done(None); + }; + let done = Cell::new(Some(done)); + // SAFETY: AppKit's documented NSOpenPanel API, on the main thread. + unsafe { + let panel: Retained = msg_send![class, openPanel]; + let _: () = msg_send![&panel, setCanChooseFiles: false]; + let _: () = msg_send![&panel, setCanChooseDirectories: true]; + let _: () = msg_send![&panel, setCanCreateDirectories: true]; + let _: () = msg_send![&panel, setAllowsMultipleSelection: false]; + let chosen = panel.clone(); + begin(surface, &panel, move |response| { + let path = if response == 1 { + let url: Option> = msg_send![&chosen, URL]; + url.as_deref().and_then(url_path) + } else { + None + }; + if let Some(done) = done.take() { + done(path); + } + }); + } +} + fn pick_paths(surface: &Rc, id: u32, request: &OpenDialog) { let Some(control) = control(surface) else { return; diff --git a/third-party/nsh/crates/nsh-platform-macos/src/image.rs b/third-party/nsh/crates/nsh-platform-macos/src/image.rs index 5111575192..d6f2c4e19d 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/image.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/image.rs @@ -55,7 +55,7 @@ use nsh_core::{ image::{ CacheHeaders, DiskCache, Fetched, Frames, Images, Key, Lease, MemoryCache, Natural, Request, Target, Want, - loader::{Context, Get, Priority, Ticket, Transport, loader}, + loader::{Context, Get, Priority, ReadAsset, Ticket, Transport, loader}, }, }; use objc2::{ @@ -125,6 +125,22 @@ pub(crate) fn set_asset_base(surface: &Rc, base: Option) { *surface.asset_base.borrow_mut() = base; } +/// Where a surface's image `asset:/` URIs go: the location its asset base +/// names, or the reader bound to an engine load. `Engine(None)` disables them +/// rather than falling back to a base. +#[derive(Default)] +pub(crate) enum AssetSource { + #[default] + Base, + Engine(Option), +} + +impl AssetSource { + pub(crate) fn engine(assets: Option>) -> Self { + Self::Engine(assets.map(|assets| ReadAsset::new(move |uri: &str| assets.read(uri)))) + } +} + /// What a load of `uri` for `surface` goes through: its asset base and, /// for remote images, its disk cache (`asset:/` URIs, served by the dev /// server during development, change as the app does: never kept). @@ -132,9 +148,13 @@ pub(crate) fn set_asset_base(surface: &Rc, base: Option) { /// but from `https` down to `http`. 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()), + }; Context { - asset_base: surface.asset_base.borrow().clone(), - read_asset: None, + asset_base, + read_asset, access: Arc::default(), disk: remote .then(|| surface.image_cache.borrow().clone()) diff --git a/third-party/nsh/crates/nsh-platform-macos/src/input.rs b/third-party/nsh/crates/nsh-platform-macos/src/input.rs index 930e302731..56a309d995 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/input.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/input.rs @@ -687,14 +687,7 @@ pub(crate) fn key(surface: &Rc, event: &NSEvent, down: bool) -> bool { surface.control(|control| control.set_focus_path(focus_path(surface))); if let Some(view) = control_activation_target(surface, event) { if down && !event.isARepeat() { - let events = view.state().events; - for kind in [EventKind::PressDown, EventKind::PressUp, EventKind::Press] { - if events.contains(kind) { - // From the keyboard: no pointer to report. - log::debug!(target: "nsh::input", "{} on {} (keyboard)", kind.prop_name(), view.id()); - surface.event(view.id(), kind, EventPayload::None); - } - } + activate(surface, &view); } return true; } @@ -762,6 +755,17 @@ pub(crate) fn key(surface: &Rc, event: &NSEvent, down: bool) -> bool { crate::scroll::key_scroll(surface, &hit.view, horizontal, m) } +/// Presses `view` from the keyboard or accessibility: no pointer to report. +pub(crate) fn activate(surface: &Surface, view: &NshView) { + let events = view.state().events; + for kind in [EventKind::PressDown, EventKind::PressUp, EventKind::Press] { + if events.contains(kind) { + log::debug!(target: "nsh::input", "{} on {} (keyboard)", kind.prop_name(), view.id()); + surface.event(view.id(), kind, EventPayload::None); + } + } +} + /// A mounted ordinary view the keyboard focus stops at (Tab): one that /// takes presses, or any `focusable` one ([`nsh_core::ViewProps::focus_stop`]), /// shown and reachable by the pointer. @@ -785,6 +789,21 @@ pub(crate) fn is_key_control(view: &NshView) -> bool { is_key_stop(view) && view.state().events.contains(EventKind::Press) } +/// What an accessibility press presses: `focusable={false}` only leaves a +/// view out of the keyboard's stops. +pub(crate) fn takes_accessibility_press(view: &NshView) -> bool { + { + let state = view.state(); + if state.kind == ViewKind::Input + || !state.events.contains(EventKind::Press) + || state.props.accessibility.state.disabled + { + return false; + } + } + view.can_target() && !view.ns().isHiddenOrHasHiddenAncestor() +} + /// Current AppKit keyboard focus, from the responder through its native /// ancestors. No mounter borrow: native callbacks may run during a mount. pub(crate) fn focus_path(surface: &Surface) -> Vec { @@ -1506,7 +1525,7 @@ pub(crate) fn retiring(surface: &Rc, cell: &NshView) { .filter(|view| !inside(view)) .cloned() .collect(); - set_hovered(surface, hovered); + set_hovered(surface, hovered, 0.0, 0.0, Mouse::now()); for slot in [&surface.input.pinch, &surface.input.rotation] { let affected = slot .borrow() diff --git a/third-party/nsh/crates/nsh-platform-macos/src/keymap.rs b/third-party/nsh/crates/nsh-platform-macos/src/keymap.rs index 99d2048fa5..90cd438413 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/keymap.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/keymap.rs @@ -13,7 +13,13 @@ //! //! A surface takes the keys of its window while the keyboard focus is in //! it (its root, one of its views, a field or hosted view inside it), or -//! nowhere (the window itself). +//! nowhere (the window itself). A binding `within` a node fires while the +//! responder's chain of native views passes through it. +//! +//! Fallback bindings run after that dispatch instead ([`unhandled`]): when +//! the focused view's `keyDown:` leaves the key, or a text view's key +//! bindings resolve it to `noop:`, which does not say which key it was: the +//! monitor keeps the last one it saw ([`unhandled_key`]). use std::{cell::RefCell, ptr::NonNull, rc::Rc, sync::Arc}; @@ -34,6 +40,7 @@ use crate::surface::Surface; pub(crate) struct KeymapState { table: RefCell>>, monitor: RefCell>>, + key: RefCell>>, } impl KeymapState { @@ -95,9 +102,28 @@ pub(crate) fn fires(surface: &Rc, event: &NSEvent) -> bool { pressed(surface, event) } -/// Whether key-down `event` fired one of the surface's bindings (then it -/// is reported to JS and consumed). +/// Whether key-down `event` fired one of the surface's ordinary bindings +/// (then it is reported to JS and consumed). fn pressed(surface: &Rc, event: &NSEvent) -> bool { + *surface.keymap.key.borrow_mut() = Some(event.into()); + crate::input::control_activation_target(surface, event).is_none() + && matched(surface, event, false) +} + +/// Whether key-down `event`, which the focused view, field, selection and +/// navigation left unhandled, fired one of the surface's fallback bindings. +pub(crate) fn unhandled(surface: &Rc, event: &NSEvent) -> bool { + matched(surface, event, true) +} + +/// [`unhandled`] for the key being dispatched, for a text view's `noop:`. +pub(crate) fn unhandled_key(surface: &Rc) -> bool { + let key = surface.keymap.key.borrow().clone(); + key.is_some_and(|key| unhandled(surface, &key)) +} + +/// Whether key-down `event` fired a binding of its phase. +fn matched(surface: &Rc, event: &NSEvent, fallback: bool) -> bool { let Some(table) = surface.keymap.table.borrow().clone() else { return false; }; @@ -110,9 +136,6 @@ fn pressed(surface: &Rc, event: &NSEvent) -> bool { let Some(editing) = editing(surface, &window) else { return false; }; - if crate::input::control_activation_target(surface, event).is_some() { - return false; - } let Some(keystroke) = keystroke(event) else { return false; }; @@ -120,7 +143,14 @@ fn pressed(surface: &Rc, event: &NSEvent) -> bool { if cfg!(feature = "dev") && keystroke.reloads(Os::MacOs) { return false; } - let found = table.find(keystroke, editing, event.isARepeat()); + let path = crate::input::focus_path(surface); + let within = |id| path.contains(&id); + let repeat = event.isARepeat(); + let found = if fallback { + table.find_fallback_with(keystroke, editing, repeat, within) + } else { + table.find_with(keystroke, editing, repeat, within) + }; if let KeyMatch::Fire(id) = found { surface.event( ROOT_ID, diff --git a/third-party/nsh/crates/nsh-platform-macos/src/lib.rs b/third-party/nsh/crates/nsh-platform-macos/src/lib.rs index ca35bb3126..c1b28bedc2 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/lib.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/lib.rs @@ -69,7 +69,10 @@ pub use objc2_foundation; #[cfg(feature = "dev")] pub use crate::app::set_root; pub use crate::{ - app::{AppOptions, AppSource, AppView, WindowDisplay, quit_app, run_app, set_ui_sets}, + app::{ + AppOptions, AppSource, AppView, WindowDisplay, quit_app, run_app, run_app_with_config, + set_ui_sets, with_running_view, + }, image::AssetBase, native::{ComponentContext, ComponentFactory, Components, NativeComponent}, text::CoreTextEngine, diff --git a/third-party/nsh/crates/nsh-platform-macos/src/mount.rs b/third-party/nsh/crates/nsh-platform-macos/src/mount.rs index 211ff7b4df..8b8fe37902 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/mount.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/mount.rs @@ -490,6 +490,11 @@ impl Mounter { crate::text_input::set_selection(view, *start, *end); } } + Command::ReplaceRange { .. } => { + if let Some(view) = view { + crate::text_input::replace_range(view, command); + } + } Command::ScrollTo { y, animated, .. } if view.is_some_and(|v| v.kind() == ViewKind::Input) => { diff --git a/third-party/nsh/crates/nsh-platform-macos/src/surface.rs b/third-party/nsh/crates/nsh-platform-macos/src/surface.rs index 90da8d1f6b..354b1b84b3 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/surface.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/surface.rs @@ -240,6 +240,9 @@ pub(crate) struct Surface { on_hot_update_failed: RefCell>>, /// Where `asset:/` image URIs resolve ([`crate::image::set_asset_base`]). pub(crate) asset_base: RefCell>, + /// The reader of `asset:/` image URIs bound to the running load, over + /// [`Self::asset_base`] ([`crate::AppView::load_with_config`]). + pub(crate) image_assets: RefCell, /// The disk cache of downloaded images ([`crate::image::set_disk_cache`]). pub(crate) image_cache: RefCell>, /// The window's chrome: title bar, menus ([`crate::chrome`]). @@ -297,6 +300,7 @@ impl Surface { on_error: RefCell::new(None), on_hot_update_failed: RefCell::new(None), asset_base: RefCell::new(None), + image_assets: RefCell::default(), image_cache: RefCell::new(None), native: Default::default(), keymap: Default::default(), diff --git a/third-party/nsh/crates/nsh-platform-macos/src/text_input.rs b/third-party/nsh/crates/nsh-platform-macos/src/text_input.rs index 63b336700d..1029906ac8 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/text_input.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/text_input.rs @@ -689,6 +689,14 @@ impl Ctx { if command == sel!(insertNewline:) { return self.return_key(&input, text_view, multiline); } + // The key bindings have no use for this key: an app's fallback may. + if command == sel!(noop:) { + return !text_view.hasMarkedText() + && self + .surface + .upgrade() + .is_some_and(|surface| crate::keymap::unhandled_key(&surface)); + } // Option+Return (`insertNewlineIgnoringFieldEditor:`) and // Control+Return (`insertLineBreak:`): a line break in a multiline // input whatever return does, a plain `\n` (a text view's @@ -2102,6 +2110,68 @@ pub(crate) fn set_selection(view: &NshView, start: u32, end: u32) { } } +/// One undoable application edit of the focused input `view`, dropped unless +/// the input still is what the app saw when it asked (`Command::ReplaceRange`). +pub(crate) fn replace_range(view: &NshView, command: &nsh_core::Command) { + let nsh_core::Command::ReplaceRange { + range, + text, + expected_text, + expected_selection, + count, + } = command + else { + return; + }; + let Some(input) = view.state().input.clone() else { + return; + }; + let editor = input.editor.borrow().clone(); + if !input.focused.get() + || !input.config.borrow().editable + || input.count.get() != *count + || editor.marked_text().is_some() + || editor.text() != *expected_text + || editor.selection() != Some((expected_selection.start, expected_selection.end)) + || !splits_no_character(expected_text, range) + { + return; + } + let Some(editing) = editor.editing() else { + return; + }; + let Some(text_view) = editing.downcast_ref::() else { + return; + }; + let target = NSRange::new(range.start as usize, (range.end - range.start) as usize); + let replacement = NSString::from_str(text); + let allowed = text_view.shouldChangeTextInRange_replacementString(target, Some(&replacement)); + if allowed { + text_view.replaceCharactersInRange_withString(target, &replacement); + } + // A clipped edit is made by `should_change`, which then refuses this one. + let now = text_view.string(); + if allowed || now.to_string() != *expected_text { + let inserted = now.length() + target.length - expected_text.encode_utf16().count(); + text_view.setSelectedRange(NSRange::new(target.location + inserted, 0)); + } + if allowed { + text_view.didChangeText(); + } +} + +/// Whether `range` (UTF-16) is inside `text` and ends between its characters. +fn splits_no_character(text: &str, range: &std::ops::Range) -> bool { + let mut offset = 0; + let mut ends = 0; + for c in text.chars() { + ends += usize::from(offset == range.start) + usize::from(offset == range.end); + offset += c.len_utf16() as u32; + } + ends += usize::from(offset == range.start) + usize::from(offset == range.end); + ends == 2 +} + /// Sets the `focused` interaction state of `view`, if JS tracks it. fn set_focused(surface: &Surface, view: &NshView, on: bool) { let (id, next) = { @@ -2160,6 +2230,16 @@ fn clip_utf16(s: &str, max: usize) -> &str { mod tests { use super::*; + #[test] + fn application_edits_keep_characters_whole() { + assert!(splits_no_character("a😀b", &(0..4))); + assert!(splits_no_character("a😀b", &(1..3))); + assert!(splits_no_character("a😀b", &(4..4))); + assert!(splits_no_character("", &(0..0))); + assert!(!splits_no_character("a😀b", &(2..3))); + assert!(!splits_no_character("a😀b", &(0..5))); + } + #[test] fn single_line_placeholder_matches_native_editor_metrics() { let font = objc2_app_kit::NSFont::systemFontOfSize(16.0); diff --git a/third-party/nsh/crates/nsh-platform-macos/src/view.rs b/third-party/nsh/crates/nsh-platform-macos/src/view.rs index de2601cb5c..c60257ced6 100644 --- a/third-party/nsh/crates/nsh-platform-macos/src/view.rs +++ b/third-party/nsh/crates/nsh-platform-macos/src/view.rs @@ -677,7 +677,8 @@ define_class!( fn key_down(&self, event: &NSEvent) { let handled = self.keyboard_surface().is_some_and(|surface| { surface.measure(false, || { - let handled = crate::input::key(&surface, event, true); + let handled = crate::input::key(&surface, event, true) + || crate::keymap::unhandled(&surface, event); // Keys scroll too. quiet(|| surface.revisit()); handled @@ -726,6 +727,17 @@ define_class!( } } + /// VoiceOver's press, as Return presses a focused control. + #[unsafe(method(accessibilityPerformPress))] + fn accessibility_perform_press(&self) -> bool { + let view = NshView(self.retain()); + let pressable = crate::input::takes_accessibility_press(&view); + if let (true, Some(surface)) = (pressable, self.keyboard_surface()) { + crate::input::activate(&surface, &view); + } + pressable + } + #[unsafe(method(viewDidMoveToSuperview))] fn view_did_move_to_superview(&self) { // AppKit resets the layer's geometry when it attaches the diff --git a/third-party/nsh/crates/nsh-platform-macos/tests/keymap/app.tsx b/third-party/nsh/crates/nsh-platform-macos/tests/keymap/app.tsx new file mode 100644 index 0000000000..5c048147f1 --- /dev/null +++ b/third-party/nsh/crates/nsh-platform-macos/tests/keymap/app.tsx @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { effect, keymap, mount, signal, Text, View, type ViewRef } from "nsh"; +import { Input, type InputRef } from "nsh/base"; + +function App() { + const card = signal(null); + const escapes = signal(0); + const moves = signal(0); + const text = signal(""); + const vault = signal(null); + const interrupts = signal(0); + const limit = signal(null); + let limited: InputRef | null = null; + let field: InputRef | null = null; + const clip = () => + limited?.replaceRange({ + start: 0, + end: 2, + text: "WXYZ", + expectedText: "abcdef", + expectedSelection: { start: 6, end: 6 }, + }); + effect(() => { + const within = vault(); + if (within) + return keymap.bind("ctrl+c", () => interrupts.set(interrupts() + 1), { + when: "always", + within, + fallback: true, + }); + }); + effect(() => { + const within = limit(); + if (within) return keymap.bind("ctrl+g", clip, { when: "always", within }); + }); + const clear = () => { + const end = text().length; + field?.replaceRange({ + start: 0, + end, + text: "", + expectedText: text(), + expectedSelection: { start: end, end }, + }); + }; + effect(() => { + const within = card(); + if (!within) return; + const stops = [ + keymap.bind("ctrl+c", clear, { when: "always", within, fallback: true }), + keymap.bind("ctrl+a", () => moves.set(moves() + 1), { when: "always", within, fallback: true }), + keymap.bind("escape", () => escapes.set(escapes() + 1), { when: "always", within }), + ]; + return () => stops.forEach((stop) => stop()); + }); + return + card.set(node)}> + { field = node; }} + style={{ height: 40 }} + onChange={({ value }) => text.set(value)} + /> + + + vault.set(node)}> + + + limit.set(node)}> + { limited = node; }} + style={{ height: 40 }} + /> + + {`escapes: ${escapes()}, moves: ${moves()}`} + {`interrupts: ${interrupts()}`} + ; +} + +mount(App); diff --git a/third-party/nsh/crates/nsh-platform-macos/tests/keymap/phases.test.tsx b/third-party/nsh/crates/nsh-platform-macos/tests/keymap/phases.test.tsx new file mode 100644 index 0000000000..d5c9434d34 --- /dev/null +++ b/third-party/nsh/crates/nsh-platform-macos/tests/keymap/phases.test.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { expect, open, test } from "nsh/test"; + +test("a binding within a node fires while the focus is inside it", async () => { + const app = await open("./app.tsx"); + await app.type("id:outside", "x"); + await app.keyboard.press("escape"); + await app.waitFor("text:escapes: 0, moves: 0"); + await app.type("id:inside", "y"); + await app.keyboard.press("escape"); + await app.waitFor("text:escapes: 1, moves: 0"); +}); + +test("a fallback binding fires on the keys the field leaves, and edits it", async () => { + const app = await open("./app.tsx"); + await app.type("id:outside", "kept"); + await app.keyboard.press("ctrl+c"); + await app.wait(100); + expect(app.value("id:outside")).toBe("kept"); + await app.type("id:inside", "draft"); + await app.keyboard.press("ctrl+c"); + await app.wait(100); + expect(app.value("id:inside")).toBe(""); + await app.type("id:inside", "again"); + await app.keyboard.press("ctrl+a"); + await app.wait(100); + await app.waitFor("text:escapes: 0, moves: 0"); +}); + +test("a fallback binding fires in a masked field", async () => { + const app = await open("./app.tsx"); + await app.type("id:masked", "secret"); + await app.keyboard.press("ctrl+c"); + await app.waitFor("text:interrupts: 1"); +}); + +test("a replacement clipped to maxLength leaves the caret after what it inserted", async () => { + const app = await open("./app.tsx"); + await app.type("id:limited", "abcdef"); + await app.keyboard.press("ctrl+g"); + await app.wait(100); + expect(app.value("id:limited")).toBe("WXcdef"); + await app.keyboard.press("backspace"); + await app.wait(100); + expect(app.value("id:limited")).toBe("Wcdef"); +}); diff --git a/third-party/nsh/docs/dev/macos.md b/third-party/nsh/docs/dev/macos.md index 9cd6f7732d..578ac6f372 100644 --- a/third-party/nsh/docs/dev/macos.md +++ b/third-party/nsh/docs/dev/macos.md @@ -160,7 +160,7 @@ server client, log forwarding, the error overlay: `dev.rs`). The runtime needs m | Video | `native.rs` | AVFoundation (loaded at run time, as on iOS): an `AVPlayerLayer` in a view that lets the mouse through, or AVKit's `AVPlayerView` with `controls` | | Web views | `native.rs` | `WKWebView` (iOS's bridge: `window.nsh.postMessage`, injected JS, the `webview` capability's navigation policy, every prop, event and command iOS has); a web view is transparent by default (`drawsBackground = false`), a page without a background showing the view behind it; macOS's web view has no scroll view to turn off, so with `scrollable={false}` the root's `hitTest:` keeps wheel and trackpad scrolls from it (`passes_wheel`) and they scroll the app's scroll views around it (the page's own keys and scripts still scroll it; swipes between pages, `allowsBackForwardNavigationGestures`, are scrolls too) | | Native components | `native.rs` | `AppView::register_component(name, factory)`; a `NativeComponent`'s `NSView` fills the node's view; `ComponentContext::emit` / `set_size` from any thread | -| Accessibility | `access.rs` | `NSAccessibility` setters on the views: roles and subroles, labels, values, states, `testID` as the identifier; `accessible={true}` hides the children, `accessibilityElementsHidden` and `visibility: hidden` the view and its subtree (as in a browser), the view answering `accessibilityChildren` itself; live regions announced | +| Accessibility | `access.rs` | `NSAccessibility` setters on the views: roles and subroles, labels, values, states, `testID` as the identifier; `accessible={true}` hides the children, `accessibilityElementsHidden` and `visibility: hidden` the view and its subtree (as in a browser), the view answering `accessibilityChildren` itself; live regions announced; `accessibilityPerformPress` presses a view that takes presses, `focusable={false}` too | | Environment | `system.rs` | Color scheme: the app's appearance when it forces one, else the root's effective appearance (the root's appearance is set to the app's, so hosted controls follow); locale and RTL from `NSLocale`, reduce motion and VoiceOver from `NSWorkspace` (live), the backing scale factor (again when the window moves to another screen: `pixelRatio()`), the OS version; `platform.os` `"macos"`, idiom `desktop`, font scale 1, safe area and keyboard height 0 | | Window chrome (`nsh/desktop`) | `chrome.rs` | `setTitle`; a hidden title bar is `FullSizeContentView` with a transparent, untitled title bar (the traffic lights stay), its area (native frame geometry, including `contentLayoutRect` for a toolbar, and the zoom button's right edge + 8) reported as the environment's `title_bar`, 0 in full screen; a left press on a `windowDrag` view calls `performWindowDragWithEvent:` and a double click does what `AppleActionOnDoubleClick` says (zoom by default), the press never reaching the app; `setFrame` merges the fields given with the window's frame and calls `setFrame:display:animate:` (`x`/`y` are the top left corner, down from the primary screen's top as on the other platforms: `appkit_frame` flips `y` at the height of `NSScreen.screens[0]`, whose bottom is the origin of Cocoa's screen coordinates on every display arrangement, and a `height` without a `y` keeps the top edge), `setLevel` maps to an `NSWindowLevel` (`desktopIcon` is `kCGDesktopIconWindowLevel`, and it also makes the window stationary on every space, out of Mission Control and Cmd+`); `orderFront` deminiaturizes, activates the app and makes the window key; the application menu replaces the main menu (its first submenu is the app menu; the replaced one comes back with `null`), context menus are `popUpMenuPositioningItem:atLocation:inView:` from the main queue (they track the mouse in a loop of their own; one the app opens without a place opens where the root's last pointer event was (a move, or a press or release of any button: a right click's menu opens at that click), else at the cursor: the test agent's input leaves the cursor where it is); role items send AppKit's standard actions down the responder chain and are not reported, the others target an `NshMenuTarget` that reports their tag; AppKit validates the items (roles are enabled and titled by the responder chain: `Undo Typing`, `Exit Full Screen`), a disabled item's action is one nothing answers; an app's bar height selects an empty `NSToolbar` (`UnifiedCompact` up to 44pt, `Unified` above), with AppKit laying out the traffic lights on every live-resize frame; `titleBar().height` reports the actual unobscured-content boundary from `contentLayoutRect`, refreshed after deferred layout; a host's existing toolbar and style stay untouched; NSH tracks its own toolbar by object identity and restores the window's previous style when removing it, moving between windows or dropping the surface | @@ -196,13 +196,18 @@ any layout; the dev host's Cmd+R is a local key monitor (`dev.rs`). changes, and as it ends, after the `onChangeText` of the committed text; leaving the field ends it too). A value from JS that arrives during a composition is held and applied when the composition ends, if JS has seen every edit by then, so the input method's session continues undisturbed. +- **`replaceRange`** (`text_input::replace_range`) edits through the text view (`shouldChangeTextInRange:` → + `didChangeText`), so undo and `Ctx::should_change` see it. It does nothing unless the input is focused and editable, + JS has seen every edit, no composition is marked, the text and selection are what the command expects, and the + range splits no character. - **At the root** (`view.rs` `keyDown:` → `input::key`): the selected text's Cmd+A / Cmd+C / Escape (`text.rs`; also the root's `selectAll:` / `copy:` for the menu), going back (`navigation::key_pressed`: Escape, Option+Left, Cmd+[), Tab / Shift+Tab into fields and pressable views, and scrolling the scroll view around the focused view or under the mouse: arrows by a line, Page Up / Down, Option+Up / Down and Space / Shift+Space by a page, Home / End and Cmd+Up / Down to the edges. The mouse's back button goes back from `input::event`. - **The app's shortcuts** (`nsh`'s `keymap`, `keymap.rs`): while the surface has bindings, a local key-down monitor - matches every key of its window against the mounted table (`Keymap::find`) before AppKit dispatches it, so they + matches every key of its window against the mounted table (`Keymap::find_with`, a binding `within` a node firing + while the responder's chain of views passes through it) before AppKit dispatches it, so they come before menus' key equivalents, the field editor's key bindings and the root's keys (Escape going back, Tab, scrolling); a match is consumed and reported as the root's `KeyCommand`. Quit, Hide, Hide Others, Minimize and Full Screen cannot be bound (`Keystroke::reserved`), so the app menu keeps them; Cmd+W, the Edit menu's keys @@ -213,6 +218,12 @@ any layout; the dev host's Cmd+R is a local key monitor (`dev.rs`). window itself. The key is read as `Keystroke`: virtual key codes for named and function keys, else the character without modifiers (`charactersByApplyingModifiers:` with none when Shift is held, so Shift+/ is `shift+/`), the US layout's (`input::us_key`) when that is not ASCII. The monitor is removed when the table empties or the app stops. +- **Fallback shortcuts** (`keymap::unhandled`, `Keymap::find_fallback_with`) run after that dispatch, on keys nothing + used: the root's `keyDown:` tries them when `input::key` leaves the key, and a text view's `doCommandBySelector:` + when its key bindings resolve the key to `noop:` (Ctrl+C) and no composition is marked. `noop:` does not say which + key it was, so the key comes from the monitor, which saw it first (`keymap::unhandled_key`). Headless apps refuse these + bindings, so `crates/nsh-platform-macos/tests/keymap/` runs in a window: + `nsh test crates/nsh-platform-macos/tests/keymap/phases.test.tsx --target macos`. - Shortcuts NSH matches itself (Cmd+A / C / [ / R) read `input::shortcut_key`: the character without modifiers, or on a layout that types none in ASCII the US layout's key, as menus do. @@ -284,7 +295,7 @@ rings of box shadows are missing from them, and so are outer box shadows under a and later (looked up at run time, `setStyle:` regular / clear, `setCornerRadius:` the shared radius); before, the blur stands in, and glass alone draws nothing. Not yet seen on macOS 26: written and checked on macOS 15, where the class is missing. -- **Inputs**: content types / autofill, keyboard types, autocapitalization, letter spacing; input accessory views are +- **Inputs**: `tokens`, content types / autofill, keyboard types, autocapitalization, letter spacing; input accessory views are never shown (there is no software keyboard); `keyboardShouldPersistTaps` is ignored. - **Accessibility**: `accessibilityActions`. - **No desktop equivalent**: the status bar, haptics; the safe area and keyboard height are 0. @@ -324,6 +335,21 @@ state and transfers, repeated reopen without duplicate windows, Quit while hidden, a test's quit, and cleanup after run_app returns. Source implementation and CI compilation alone do not prove those runtime behaviors. +### Hosts with an engine of their own + +`run_app_with_config` runs the standalone window over the host's +`EngineConfig` (`AppView::load_with_config` in an embedded view): its modules, +capabilities and assets. Images resolve `asset:/` through that config's reader, +cached per load, so a second load never shows the first one's bytes. +`with_running_view` reaches the running view from any thread, for what the host +asks of the window later (`AppView::choose_directory`, `set_font_scale`), and +the third argument hears the window become and stop being the key window. + +`AppOptions::min_size` is the content's minimum. `AppOptions::frame_autosave_name` +saves the window's frame under that name in `NSUserDefaults` and restores it on +the next launch; a title bar change keeps such a frame rather than the content's +size, which would shrink it by the title bar on every launch. + ## Keyboard focus for views A mounted non-input view that is a focus stop participates in AppKit's key