diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 71b9e01a1..d7539ba6c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -194,6 +194,52 @@ jobs: -srcfolder "$STAGE" \ -fs HFS+ -format UDZO -ov \ "OpenPencil-$OP_VERSION-${{ matrix.arch }}-mac.dmg" + # ─── Code-signing scaffolding (NO-OP until secrets exist) ─────────── + # Until the secrets below are configured in the repo/org settings, + # EVERY signing step is skipped and releases ship ad-hoc-signed + # (macOS, via scripts/bundle-macos.sh) / unsigned (Windows) — the + # current behavior, unchanged. Each step is gated on its first + # required secret being non-empty, so adding the secrets is the only + # action needed to turn signing on. + # + # Required macOS secrets (Developer ID + notarization): + # APPLE_CERTIFICATE_P12 base64 of the Developer ID Application + # .p12 export + # APPLE_CERTIFICATE_PASSWORD password for that .p12 + # APPLE_TEAM_ID 10-char Apple Developer Team ID + # APPLE_ID Apple ID email used for notarization + # APPLE_APP_PASSWORD app-specific password for notarytool + # + # Required Windows secrets (Authenticode): + # WINDOWS_CERT_BASE64 base64 of the code-signing .pfx + # WINDOWS_CERT_PASSWORD password for that .pfx + - name: Import codesign certificate (macos) + if: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_P12 != '' }} + uses: apple-actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }} + p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + - name: Codesign + notarize DMG (macos) + if: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_P12 != '' }} + shell: bash + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + run: | + DMG="OpenPencil-$OP_VERSION-${{ matrix.arch }}-mac.dmg" + # The .app inside the DMG was ad-hoc signed by bundle-macos.sh; + # re-sign the DMG container with the real Developer ID identity, + # then notarize + staple so Gatekeeper accepts the download. + IDENTITY="Developer ID Application ($APPLE_TEAM_ID)" + codesign --force --timestamp --options runtime \ + --sign "$IDENTITY" "$DMG" + xcrun notarytool submit "$DMG" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --wait + xcrun stapler staple "$DMG" - name: Package NSIS installer (windows) if: runner.os == 'Windows' shell: pwsh @@ -208,6 +254,33 @@ jobs: "/DICON_FILE=$env:GITHUB_WORKSPACE\apps\desktop\build\icon.ico" ` "/DOUT_FILE=$env:GITHUB_WORKSPACE\OpenPencil-$env:OP_VERSION-${{ matrix.arch }}-win-setup.exe" ` scripts\package-windows.nsi + # Authenticode signing scaffolding (NO-OP until WINDOWS_CERT_BASE64 + # exists — see the secrets comment block in the macOS section above). + # Until then the NSIS installer ships unsigned (current behavior). + - name: Sign NSIS installer (windows) + if: ${{ runner.os == 'Windows' && secrets.WINDOWS_CERT_BASE64 != '' }} + shell: pwsh + env: + WINDOWS_CERT_BASE64: ${{ secrets.WINDOWS_CERT_BASE64 }} + WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} + run: | + # Materialize the .pfx from the base64 secret into a temp file. + $pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx" + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERT_BASE64)) + $exe = "OpenPencil-$env:OP_VERSION-${{ matrix.arch }}-win-setup.exe" + # signtool ships with the Windows SDK preinstalled on + # windows-latest. RFC-3161 timestamp so signatures outlive the + # cert's validity window. + $signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object FullName -Descending | Select-Object -First 1).FullName + & $signtool sign ` + /f $pfx ` + /p $env:WINDOWS_CERT_PASSWORD ` + /fd SHA256 ` + /tr http://timestamp.digicert.com ` + /td SHA256 ` + $exe + Remove-Item $pfx -Force - name: Package .deb (linux) if: runner.os == 'Linux' shell: bash diff --git a/Cargo.lock b/Cargo.lock index 2a50e124c..39fe896c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,13 +20,83 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ "uuid", ] +[[package]] +name = "accesskit_atspi_common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e98018dbef3583d751dbb96e07b8728fb99581360e1c3df408af16f4a80b821" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant 5.12.0", +] + +[[package]] +name = "accesskit_consumer" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17cb8b66cef272d48161b02a6317cc2bdd5f98bb0a5e79c68f704a5862aa396b" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5376ba4cc23312587634abb5250b1ce8618f01a55915608209aafd01efb4bf8c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus 5.16.0", +] + +[[package]] +name = "accesskit_windows" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "adler2" version = "2.0.1" @@ -217,7 +287,7 @@ dependencies = [ "serde", "serde_repr", "url", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -440,6 +510,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus 5.16.0", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names 4.3.2", + "zvariant 5.12.0", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus 5.16.0", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -1725,6 +1832,15 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -3092,6 +3208,10 @@ dependencies = [ name = "op-host-desktop" version = "0.8.0" dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", "agent", "anthropic-agent-sdk", "arboard", @@ -3140,6 +3260,7 @@ dependencies = [ name = "op-host-native" version = "0.8.0" dependencies = [ + "accesskit", "casement", "glow", "glutin", @@ -3167,6 +3288,7 @@ name = "op-host-web" version = "0.8.0" dependencies = [ "accesskit", + "base64", "console_error_panic_hook", "jian-core", "jian-ops-schema", @@ -3384,6 +3506,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -3560,6 +3725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" dependencies = [ "memchr", + "serde", ] [[package]] @@ -4394,6 +4560,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "skia-bindings" version = "0.97.2" @@ -4572,7 +4744,7 @@ dependencies = [ "ntapi", "once_cell", "rayon", - "windows", + "windows 0.52.0", ] [[package]] @@ -5547,10 +5719,31 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ - "windows-core", + "windows-core 0.52.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -5560,12 +5753,86 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -5659,6 +5926,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6106,9 +6382,68 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.16.0", + "zbus_names 4.3.2", + "zvariant 5.12.0", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant 5.12.0", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant 5.12.0", ] [[package]] @@ -6121,7 +6456,22 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names 4.3.2", + "zvariant 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -6132,7 +6482,30 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow", + "zvariant 5.12.0", +] + +[[package]] +name = "zbus_xml" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +dependencies = [ + "quick-xml", + "serde", + "zbus_names 4.3.2", + "zvariant 5.12.0", ] [[package]] @@ -6331,7 +6704,21 @@ dependencies = [ "serde", "static_assertions", "url", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -6344,7 +6731,20 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 3.4.0", ] [[package]] @@ -6357,3 +6757,16 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/README.de.md b/README.de.md index 90c1b4794..73b32fc73 100644 --- a/README.de.md +++ b/README.de.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . Global installieren und das Design-Tool vom Terminal aus steuern: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.es.md b/README.es.md index 003df3f82..aa2f08114 100644 --- a/README.es.md +++ b/README.es.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . Instala globalmente y controla la herramienta de diseño desde tu terminal: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.fr.md b/README.fr.md index 149283852..5ba8e5cb3 100644 --- a/README.fr.md +++ b/README.fr.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . Installez globalement et contrôlez l'outil de design depuis votre terminal : ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.hi.md b/README.hi.md index 2b4819791..eb9c72f07 100644 --- a/README.hi.md +++ b/README.hi.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . वैश्विक रूप से इंस्टॉल करें और अपने टर्मिनल से डिज़ाइन टूल को नियंत्रित करें: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.id.md b/README.id.md index 1dce9a3b8..c9d47d71e 100644 --- a/README.id.md +++ b/README.id.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . Instal secara global dan kontrol alat desain dari terminal Anda: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.ja.md b/README.ja.md index f1fa826ea..427e33743 100644 --- a/README.ja.md +++ b/README.ja.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . グローバルインストールしてターミナルからデザインツールを操作: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.ko.md b/README.ko.md index c516af7a7..cce65ce53 100644 --- a/README.ko.md +++ b/README.ko.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . 전역 설치 후 터미널에서 디자인 도구를 제어하세요: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.md b/README.md index b39ab6b97..e9d6a83e3 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,13 @@ scoop install openpencil **CLI (`op`):** ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op +``` + +Or use the install script (macOS / Linux): + +```bash +curl -fsSL https://raw.githubusercontent.com/ZSeven-W/openpencil/main/scripts/install-op.sh | bash ``` ## Cloning(含 Rust 子系统) @@ -275,10 +281,10 @@ docker build --target full -t openpencil-full . ## CLI — `op` -Install globally and control the design tool from your terminal: +Install via Homebrew and control the design tool from your terminal: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.pt.md b/README.pt.md index ad40d43f2..405d0ca3b 100644 --- a/README.pt.md +++ b/README.pt.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . Instale globalmente e controle a ferramenta de design pelo terminal: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.ru.md b/README.ru.md index 1d36e81ad..59373bd96 100644 --- a/README.ru.md +++ b/README.ru.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . Установите глобально и управляйте инструментом дизайна из терминала: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.th.md b/README.th.md index 5fd1abedd..82c04e277 100644 --- a/README.th.md +++ b/README.th.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . ติดตั้งแบบ global และควบคุมเครื่องมือออกแบบจาก terminal ของคุณ: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.tr.md b/README.tr.md index 43e453a29..081f0c96c 100644 --- a/README.tr.md +++ b/README.tr.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . Global olarak yükleyin ve tasarım aracını terminalinizden kontrol edin: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.vi.md b/README.vi.md index 760a48425..0c8fb12b1 100644 --- a/README.vi.md +++ b/README.vi.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . Cài đặt toàn cục và điều khiển công cụ thiết kế từ terminal của bạn: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.zh-TW.md b/README.zh-TW.md index 44910a23b..a0274748f 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . 全域安裝後即可從終端機控制設計工具: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/README.zh.md b/README.zh.md index 35b544e97..b0f7ed19f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -213,7 +213,7 @@ docker build --target full -t openpencil-full . 全局安装后即可从终端控制设计工具: ```bash -npm install -g @zseven-w/openpencil +brew install zseven-w/openpencil/op ``` ```bash diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index 2241f4a0a..d29756972 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -519,16 +519,19 @@ impl EditorState { let Some(source_ref) = find_node(children, &source) else { return false; }; - if walkers::descendant_contains(source_ref, &parent) - || find_node(children, &parent).is_none() - { + // The target must be a container that accepts children, verified + // BEFORE extracting the source. Otherwise a non-container target would + // make `prepend_into` bounce the payload (`Err`) AFTER the node was + // already removed, silently dropping it from the document. + let parent_accepts = find_node(children, &parent).is_some_and(PenNodeExt::is_container); + if walkers::descendant_contains(source_ref, &parent) || !parent_accepts { return false; } let children = self.active_children_mut(); let Some(node) = walkers::extract_node(children, &source) else { return false; }; - walkers::append_into(children, &parent, node).is_ok() + walkers::prepend_into(children, &parent, node).is_ok() } fn reorder_relative(&mut self, source: NodeId, anchor: NodeId, before: bool) -> bool { diff --git a/crates/op-editor-core/src/tests_mutators.rs b/crates/op-editor-core/src/tests_mutators.rs index f182c4f4b..a1e9aeebf 100644 --- a/crates/op-editor-core/src/tests_mutators.rs +++ b/crates/op-editor-core/src/tests_mutators.rs @@ -6,7 +6,7 @@ use crate::node_id::NodeId; use crate::pen_node_ext::PenNodeExt; -use crate::test_support::{frame, group, rect, sample, state_with}; +use crate::test_support::{ellipse, frame, group, rect, sample, state_with}; use crate::walkers::{find_node, ReorderDirection}; use jian_ops_schema::style::PenFill; use jian_ops_schema::variable::{VariableKind, VariableScalar}; @@ -336,6 +336,44 @@ fn reorder_into_reparents_under_container() { assert_eq!(parent.children().unwrap().len(), 1); } +#[test] +fn reorder_into_inserts_at_front() { + let mut s = state_with(vec![ + frame( + "n1", + "Frame", + 0.0, + 0.0, + 100.0, + 100.0, + vec![rect("c0", "Existing", 0.0, 0.0, 10.0, 10.0)], + ), + rect("n2", "Loose", 0.0, 0.0, 10.0, 10.0), + ]); + assert!(s.reorder_into(NodeId::new("n2"), NodeId::new("n1"))); + let parent = find_node(s.active_children(), &NodeId::new("n1")).unwrap(); + let ids: Vec<&str> = parent + .children() + .unwrap() + .iter() + .map(|n| n.id_str()) + .collect(); + // TS parity (layer-dnd-utils.ts): dropped node lands at index 0. + assert_eq!(ids, vec!["n2", "c0"]); +} + +#[test] +fn reorder_into_rejects_non_container_target() { + let mut s = state_with(vec![ + ellipse("e1", "Circle", 0.0, 0.0, 50.0, 50.0), + rect("n2", "Loose", 0.0, 0.0, 10.0, 10.0), + ]); + // An ellipse is not a container — the move must be refused and the source + // must survive at the root (no extract-before-verify silent drop). + assert!(!s.reorder_into(NodeId::new("n2"), NodeId::new("e1"))); + assert_eq!(root_ids(&s), vec!["e1", "n2"]); +} + #[test] fn reorder_into_rejects_cycle() { let mut s = state_with(vec![frame( diff --git a/crates/op-editor-core/src/walkers.rs b/crates/op-editor-core/src/walkers.rs index 3bb5006b1..93f336216 100644 --- a/crates/op-editor-core/src/walkers.rs +++ b/crates/op-editor-core/src/walkers.rs @@ -185,6 +185,38 @@ pub fn append_into( Err(carry) } +/// Insert `node` as the FIRST child of `parent`. `Ok(())` / `Err(node)`. +/// Mirrors `append_into` but lands the payload at index 0 so a +/// layer-panel "drop into container" matches TS (`layer-dnd-utils.ts` +/// inserts at index 0 — the top of the child list). Fails (bounces the +/// payload) when `parent` is not a container. +#[allow(clippy::result_large_err)] +pub fn prepend_into( + children: &mut [PenNode], + parent: &NodeId, + node: PenNode, +) -> Result<(), PenNode> { + if let Some(idx) = children.iter().position(|n| n.id_str() == parent.as_str()) { + match children[idx].children_mut() { + Some(grand) => { + grand.insert(0, node); + return Ok(()); + } + None => return Err(node), + } + } + let mut carry = node; + for child in children.iter_mut() { + if let Some(grand) = child.children_mut() { + match prepend_into(grand, parent, carry) { + Ok(()) => return Ok(()), + Err(returned) => carry = returned, + } + } + } + Err(carry) +} + /// Swap the node matching `target` with its next / prev sibling. pub fn reorder_in_children( children: &mut [PenNode], diff --git a/crates/op-editor-ui/src/accessibility.rs b/crates/op-editor-ui/src/accessibility.rs new file mode 100644 index 000000000..d970f2428 --- /dev/null +++ b/crates/op-editor-ui/src/accessibility.rs @@ -0,0 +1,294 @@ +//! Platform-free accessibility-tree assembler (#67). +//! +//! Every [`Widget`](crate::widgets::Widget) already exposes an +//! `accesskit::Node` via `Widget::access_node()`, but nothing assembled +//! those per-widget nodes into a single `accesskit::TreeUpdate` for an +//! OS accessibility adapter to consume — so the whole editor read as one +//! opaque canvas to VoiceOver / Narrator / Orca. +//! +//! This module is that missing seam. It is deliberately platform-free +//! (no winit / no skia / no platform adapter): it depends only on +//! `accesskit` + the widget facade, so it stays wasm32-clean and is +//! shared verbatim by the native + web hosts. Hosts collect the same +//! ordered set of top-level widgets they paint, pair each with the +//! screen-space rect they placed it at, and hand the slice here. +//! +//! ## Why the host supplies bounds +//! +//! `Widget::access_node()` sets a node's `role` + `label` but NOT its +//! bounds — a widget paints relative to a host-provided rect and has no +//! standing knowledge of where the host placed it. So the assembler +//! takes a `(rect, node)` pairing per widget and injects the rect as the +//! node's `bounds`. The host is the single source of truth for layout +//! (it reuses the very same enumeration its paint pass walks), so the +//! a11y tree and the painted frame never drift. +//! +//! ## NodeId convention +//! +//! `accesskit::NodeId(widget_id.0)` — the documented mapping on +//! [`WidgetId`](crate::widgets::WidgetId). The root host frame uses +//! [`ROOT_WIDGET_ID`](crate::widgets::ROOT_WIDGET_ID) (`WidgetId(0)`). + +use crate::widgets::{Widget, WidgetId, ROOT_WIDGET_ID}; +use crate::Rect; + +/// A top-level widget placed at a host-resolved screen rect. +/// +/// Hosts build one of these per region they paint (top bar, toolbar, +/// layer panel, canvas, property panel, chat, status bar, plus any open +/// overlays). The assembler reads `widget.id()` + `widget.access_node()` +/// and stamps `bounds` from `rect`. +pub struct PlacedWidget<'a> { + /// The widget — read for its stable id + its `access_node()`. + pub widget: &'a dyn Widget, + /// Screen-space rectangle the host painted the widget into. + pub bounds: Rect, +} + +impl<'a> PlacedWidget<'a> { + /// Convenience constructor. + pub fn new(widget: &'a dyn Widget, bounds: Rect) -> Self { + Self { widget, bounds } + } +} + +/// Map a `WidgetId` to its `accesskit::NodeId` per the documented +/// convention (`NodeId(WidgetId.0)`). +#[inline] +pub fn node_id(id: WidgetId) -> accesskit::NodeId { + accesskit::NodeId(id.0) +} + +/// Convert a facade [`Rect`] (origin + size, `f32`) into an +/// `accesskit::Rect` (min/max corners, `f64`). AccessKit bounds are in +/// the same screen-space coordinate system the host paints in. +#[inline] +fn to_accesskit_rect(r: Rect) -> accesskit::Rect { + let x0 = r.origin.x as f64; + let y0 = r.origin.y as f64; + accesskit::Rect { + x0, + y0, + x1: x0 + r.size.x as f64, + y1: y0 + r.size.y as f64, + } +} + +/// Build the root host node — a `Window`-roled frame whose children are +/// the supplied widget node ids, sized to the window bounds. +fn build_root(window_bounds: Rect, children: &[accesskit::NodeId]) -> accesskit::Node { + let mut root = accesskit::Node::new(accesskit::Role::Window); + root.set_label("OpenPencil"); + root.set_bounds(to_accesskit_rect(window_bounds)); + root.set_children(children.to_vec()); + root +} + +/// Assemble a complete `accesskit::TreeUpdate` for the editor. +/// +/// * `window_bounds` — the host window's screen rect (root node bounds). +/// * `widgets` — the ordered top-level widgets the host painted, each +/// paired with its placement rect. Order is preserved as the root's +/// child order (i.e. reading order for the screen reader). +/// * `focus` — the `WidgetId` that should hold keyboard focus. If it is +/// not present among `widgets` (or is the root), focus falls back to +/// the root node so the update is always self-consistent (accesskit +/// requires `focus` to name a node that exists in the tree). +/// +/// The returned update carries the full tree (`tree: Some(..)`) so it is +/// valid as an initial publish; hosts may also re-send it on every dirty +/// frame (accesskit suppresses no-op events). +pub fn assemble_tree_update( + window_bounds: Rect, + widgets: &[PlacedWidget<'_>], + focus: WidgetId, +) -> accesskit::TreeUpdate { + let root_id = node_id(ROOT_WIDGET_ID); + + // Child id list (preserves host paint / reading order). + let child_ids: Vec = + widgets.iter().map(|w| node_id(w.widget.id())).collect(); + + let mut nodes: Vec<(accesskit::NodeId, accesskit::Node)> = + Vec::with_capacity(widgets.len() + 1); + nodes.push((root_id, build_root(window_bounds, &child_ids))); + + for placed in widgets { + let id = node_id(placed.widget.id()); + let mut node = placed.widget.access_node(); + // The widget set role + label; the host owns layout, so the + // bounds come from where it was painted. + node.set_bounds(to_accesskit_rect(placed.bounds)); + nodes.push((id, node)); + } + + // Resolve focus: it must name a real node in this update. A focus + // request for a widget the host did not include this frame (e.g. a + // closed overlay) degrades to the root rather than producing an + // invalid update. + let focus_id = node_id(focus); + let focus_present = + focus != ROOT_WIDGET_ID && widgets.iter().any(|w| node_id(w.widget.id()) == focus_id); + let focus = if focus_present { focus_id } else { root_id }; + + accesskit::TreeUpdate { + nodes, + tree: Some(accesskit::Tree::new(root_id)), + tree_id: accesskit::TreeId::ROOT, + focus, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::widgets::{LayoutBox, LayoutCx, PaintCx}; + use crate::Rect; + + /// Minimal fake widget so the assembler tests don't depend on any + /// real widget's construction surface. + struct FakeWidget { + id: WidgetId, + role: accesskit::Role, + label: &'static str, + } + + impl Widget for FakeWidget { + fn id(&self) -> WidgetId { + self.id + } + fn layout(&self, _cx: &LayoutCx) -> LayoutBox { + LayoutBox { + rect: crate::widgets::rect(0.0, 0.0, 1.0, 1.0), + } + } + fn paint(&self, _cx: &mut PaintCx<'_>, _rect: Rect) {} + fn access_node(&self) -> accesskit::Node { + let mut node = accesskit::Node::new(self.role); + node.set_label(self.label); + node + } + } + + fn placed( + id: u64, + role: accesskit::Role, + label: &'static str, + bounds: Rect, + ) -> (FakeWidget, Rect) { + ( + FakeWidget { + id: WidgetId::new(id), + role, + label, + }, + bounds, + ) + } + + fn window() -> Rect { + crate::widgets::rect(0.0, 0.0, 1280.0, 800.0) + } + + #[test] + fn root_node_is_a_window_with_window_bounds() { + let update = assemble_tree_update(window(), &[], ROOT_WIDGET_ID); + let (root_id, root) = &update.nodes[0]; + assert_eq!(*root_id, node_id(ROOT_WIDGET_ID)); + assert_eq!(root.role(), accesskit::Role::Window); + let b = root.bounds().expect("root has bounds"); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 1280.0, 800.0)); + } + + #[test] + fn child_count_matches_input_and_every_child_has_a_node() { + let (top, top_r) = placed(5000, accesskit::Role::Header, "Title bar", window()); + let (tool, tool_r) = placed( + 3000, + accesskit::Role::Toolbar, + "Toolbar", + crate::widgets::rect(8.0, 48.0, 44.0, 300.0), + ); + let (canvas, canvas_r) = placed( + 4000, + accesskit::Role::Canvas, + "Canvas", + crate::widgets::rect(0.0, 40.0, 1280.0, 760.0), + ); + let widgets = [ + PlacedWidget::new(&top, top_r), + PlacedWidget::new(&tool, tool_r), + PlacedWidget::new(&canvas, canvas_r), + ]; + let update = assemble_tree_update(window(), &widgets, WidgetId::new(4000)); + + // Root + 3 children. + assert_eq!(update.nodes.len(), 4); + + // Root child list matches input order and length. + let (_, root) = &update.nodes[0]; + let child_ids: Vec<_> = root.children().to_vec(); + assert_eq!( + child_ids, + vec![ + node_id(WidgetId::new(5000)), + node_id(WidgetId::new(3000)), + node_id(WidgetId::new(4000)) + ] + ); + + // Every advertised child id maps to a real node in `nodes`. + for child in &child_ids { + assert!( + update.nodes.iter().any(|(id, _)| id == child), + "child {child:?} has no node entry" + ); + } + } + + #[test] + fn each_child_node_carries_its_widget_role_label_and_host_bounds() { + let bounds = crate::widgets::rect(10.0, 20.0, 30.0, 40.0); + let (canvas, _) = placed(4000, accesskit::Role::Canvas, "Canvas", bounds); + let widgets = [PlacedWidget::new(&canvas, bounds)]; + let update = assemble_tree_update(window(), &widgets, WidgetId::new(4000)); + + let (_, node) = update + .nodes + .iter() + .find(|(id, _)| *id == node_id(WidgetId::new(4000))) + .expect("canvas node present"); + assert_eq!(node.role(), accesskit::Role::Canvas); + assert_eq!(node.label(), Some("Canvas")); + let b = node.bounds().expect("canvas has host bounds"); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 20.0, 40.0, 60.0)); + } + + #[test] + fn focus_resolves_to_a_real_node_in_the_tree() { + let (canvas, r) = placed(4000, accesskit::Role::Canvas, "Canvas", window()); + let widgets = [PlacedWidget::new(&canvas, r)]; + let update = assemble_tree_update(window(), &widgets, WidgetId::new(4000)); + assert_eq!(update.focus, node_id(WidgetId::new(4000))); + // The focus id must be one of the emitted nodes. + assert!(update.nodes.iter().any(|(id, _)| *id == update.focus)); + } + + #[test] + fn focus_on_absent_widget_degrades_to_root() { + let (canvas, r) = placed(4000, accesskit::Role::Canvas, "Canvas", window()); + let widgets = [PlacedWidget::new(&canvas, r)]; + // Ask focus for a widget that wasn't included this frame. + let update = assemble_tree_update(window(), &widgets, WidgetId::new(7000)); + assert_eq!(update.focus, node_id(ROOT_WIDGET_ID)); + assert!(update.nodes.iter().any(|(id, _)| *id == update.focus)); + } + + #[test] + fn tree_root_is_the_root_widget_id() { + let update = assemble_tree_update(window(), &[], ROOT_WIDGET_ID); + let tree = update.tree.expect("initial update carries tree"); + assert_eq!(tree.root, node_id(ROOT_WIDGET_ID)); + assert_eq!(update.tree_id, accesskit::TreeId::ROOT); + } +} diff --git a/crates/op-editor-ui/src/lib.rs b/crates/op-editor-ui/src/lib.rs index 6131be020..e081e655d 100644 --- a/crates/op-editor-ui/src/lib.rs +++ b/crates/op-editor-ui/src/lib.rs @@ -30,6 +30,7 @@ pub use op_i18n as i18n; // op-editor-core; re-exported as `render_backend` and at the crate root. pub use op_editor_core::render_backend; +pub mod accessibility; pub mod layout_scene; pub mod layout_scene_hit; pub mod scene_vars; diff --git a/crates/op-editor-ui/src/widgets/layer_panel.rs b/crates/op-editor-ui/src/widgets/layer_panel.rs index cad49f79d..04bbe608d 100644 --- a/crates/op-editor-ui/src/widgets/layer_panel.rs +++ b/crates/op-editor-ui/src/widgets/layer_panel.rs @@ -149,7 +149,12 @@ impl LayerPanel { locked: base.locked.unwrap_or(false), collapsed: state.editor_ui.collapsed_layers.contains(source), hovered: false, - is_container: matches!(node, PenNode::Frame(_) | PenNode::Group(_)), + // Reparent-into drop targets match TS CONTAINER_TYPES + // (layer-panel.tsx:14 — frame/group/rectangle/ref). + is_container: matches!( + node, + PenNode::Frame(_) | PenNode::Group(_) | PenNode::Rectangle(_) | PenNode::Ref(_) + ), renaming: false, is_reusable: matches!(node, PenNode::Frame(f) if f.reusable == Some(true)), is_instance: matches!(node, PenNode::Ref(_)), diff --git a/crates/op-editor-ui/src/widgets/layer_panel_walkers.rs b/crates/op-editor-ui/src/widgets/layer_panel_walkers.rs index be611843d..d7823185c 100644 --- a/crates/op-editor-ui/src/widgets/layer_panel_walkers.rs +++ b/crates/op-editor-ui/src/widgets/layer_panel_walkers.rs @@ -138,7 +138,12 @@ fn item_for(node: &PenNode, cx: &WalkCx<'_>, depth: usize) -> LayerItem { locked: base.locked.unwrap_or(false), collapsed: cx.ui.collapsed_layers.contains(&canon), hovered: cx.hovered.map(|h| h.as_str() == base.id).unwrap_or(false), - is_container: matches!(node, PenNode::Frame(_) | PenNode::Group(_)), + // Reparent-into drop targets match TS CONTAINER_TYPES + // (layer-panel.tsx:14 — frame/group/rectangle/ref). + is_container: matches!( + node, + PenNode::Frame(_) | PenNode::Group(_) | PenNode::Rectangle(_) | PenNode::Ref(_) + ), renaming: false, is_reusable: matches!(node, PenNode::Frame(f) if f.reusable == Some(true)), is_instance: matches!(node, PenNode::Ref(_)), diff --git a/crates/op-host-desktop/Cargo.toml b/crates/op-host-desktop/Cargo.toml index 3cecaa768..17c99ebe2 100644 --- a/crates/op-host-desktop/Cargo.toml +++ b/crates/op-host-desktop/Cargo.toml @@ -63,6 +63,13 @@ role = "Editor" # render-backend / layout scene paths resolve through op-editor-ui. op-editor-ui = { path = "../op-editor-ui" } op-host-native = { path = "../op-host-native", version = "0.8.0" } +# Accessibility (#67) — the platform-free `accesskit` core types +# (`TreeUpdate` / `ActionRequest` / `ActivationHandler` …) used by the +# desktop a11y adapter (`src/a11y.rs`). The OS-specific subclassing +# adapters live in the per-target blocks below. Pinned to 0.24 to match +# op-editor-ui / op-host-native + the adapter crates' transitive +# accesskit (0.24.x), so only one accesskit lives in the tree. +accesskit = "0.24" # Phase 5 strangler reorg: the stdio MCP server (`mcp_serve.rs`) drives an # `op_editor_core::EditorState` (canonical `.op` document) instead of the # old shell-core `Document`. @@ -261,3 +268,24 @@ objc2-foundation = { version = "0.2", default-features = false, features = [ "NSString", "NSProcessInfo", ] } +# Accessibility (#67) — macOS AT-SPI bridge. `SubclassingAdapter` +# subclasses the window's NSView (raw pointer from +# `Window::window_handle()`) so VoiceOver reads the assembled +# `accesskit::TreeUpdate`. This is the raw-window-handle adapter, NOT +# `accesskit_winit` — the latter hard-depends on upstream `winit`, which +# would pull a second, incompatible winit alongside `casement` (the same +# reason `glutin-winit` is rejected). 0.26.x's transitive accesskit is +# 0.24.x, matching the workspace pin — no second accesskit. +accesskit_macos = "0.26" + +# Accessibility (#67) — Windows UI Automation bridge. `SubclassingAdapter` +# subclasses the HWND (from `Window::window_handle()` → `RawWindowHandle::Win32`). +# Scaffolded + compiled here; on-device Narrator verification is pending. +[target.'cfg(target_os = "windows")'.dependencies] +accesskit_windows = "0.33" + +# Accessibility (#67) — Linux AT-SPI (D-Bus) bridge. The Unix adapter is +# windowless (`Adapter::new` takes only the handler trio, no window +# handle). Scaffolded + compiled here; on-device Orca verification is pending. +[target.'cfg(target_os = "linux")'.dependencies] +accesskit_unix = "0.22" diff --git a/crates/op-host-desktop/src/a11y.rs b/crates/op-host-desktop/src/a11y.rs new file mode 100644 index 000000000..b432acc8d --- /dev/null +++ b/crates/op-host-desktop/src/a11y.rs @@ -0,0 +1,253 @@ +//! Desktop accessibility adapter (#67). +//! +//! Bridges the host's assembled `accesskit::TreeUpdate` (built by +//! `op_host_native::WidgetHostNative::accessibility_tree_update`) to the +//! OS accessibility API so VoiceOver / Narrator / Orca can read the +//! editor, which otherwise looks like one opaque canvas. +//! +//! ## Why the raw-window-handle adapters, not `accesskit_winit` +//! +//! The desktop windowing crate is `casement` (a winit fork), imported as +//! `winit` via `package = "casement"`. `accesskit_winit` hard-depends on +//! the *upstream* `winit` crate, which would pull a second, incompatible +//! winit into the build — the same reason `glutin-winit` is rejected +//! (see the Cargo.toml comments). Instead this uses the platform +//! subclassing adapters keyed off the raw window handle, which depend +//! only on `accesskit` + `raw-window-handle`: +//! +//! - macOS: `accesskit_macos::SubclassingAdapter` (NSView pointer) +//! - Windows: `accesskit_windows::SubclassingAdapter` (HWND) +//! - Linux: `accesskit_unix::Adapter` (AT-SPI, windowless) +//! +//! ## Lifecycle +//! +//! The platform adapter pulls the *initial* tree lazily through an +//! [`ActivationHandler`] (assistive tech may not be running at window +//! creation). Subsequent frames are pushed via [`DesktopA11y::push`]: +//! it caches the latest tree (so a late activation gets a fresh one) and +//! calls the adapter's `update_if_active`, raising any queued platform +//! events. Incoming [`accesskit::ActionRequest`]s (Focus / Click) land +//! in a thread-safe queue the runner drains each frame and routes back +//! into host state via `WidgetHostNative::apply_a11y_action`. + +use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +/// Latest assembled tree, shared with the platform adapter's activation +/// handler (which may run on a platform thread). +type SharedTree = Arc>>; + +/// Queue of action requests from assistive tech, drained by the runner. +type ActionQueue = Arc>>; + +/// Returns the cached full tree to the platform adapter on activation. +struct CachedTreeActivation { + tree: SharedTree, +} + +impl ActivationHandler for CachedTreeActivation { + fn request_initial_tree(&mut self) -> Option { + self.tree.lock().ok().and_then(|t| t.clone()) + } +} + +/// Pushes incoming action requests onto the shared queue. +struct QueueingActionHandler { + queue: ActionQueue, +} + +impl ActionHandler for QueueingActionHandler { + fn do_action(&mut self, request: ActionRequest) { + if let Ok(mut q) = self.queue.lock() { + q.push_back(request); + } + } +} + +/// A drained, host-level accessibility action: which editor region the +/// assistive tech targeted, and whether it was a focus (vs activation) +/// request. The runner feeds these to `WidgetHostNative::apply_a11y_action`. +pub struct A11yAction { + /// Raw `accesskit::NodeId.0` == `WidgetId.0` of the target region. + pub target: u64, + /// `true` for `Action::Focus`; `false` for `Click` / `Default`. + pub is_focus: bool, +} + +/// Desktop accessibility adapter. One per window; created once the +/// window's raw handle is available, then fed a fresh tree each dirty +/// frame. +pub struct DesktopA11y { + /// Platform adapter. macOS / Windows subclass the view / HWND; Linux + /// is windowless. `None` if the window handle could not be resolved + /// (the editor still runs, just without the a11y bridge). + #[cfg(target_os = "macos")] + adapter: Option, + #[cfg(target_os = "windows")] + adapter: Option, + #[cfg(target_os = "linux")] + adapter: Option, + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + adapter: Option<()>, + + tree: SharedTree, + queue: ActionQueue, +} + +impl DesktopA11y { + /// Build the adapter for `window`. The platform adapter is created + /// from the window's raw handle; if that can't be resolved the + /// adapter is left inert (every method becomes a no-op) so the editor + /// still runs. + pub fn new(window: &winit::window::Window) -> Self { + let tree: SharedTree = Arc::new(Mutex::new(None)); + let queue: ActionQueue = Arc::new(Mutex::new(VecDeque::new())); + let adapter = Self::build_adapter(window, tree.clone(), queue.clone()); + Self { + adapter, + tree, + queue, + } + } + + /// Cache `update` and hand it to the platform adapter, raising any + /// queued platform events. Cheap when assistive tech isn't active + /// (`update_if_active` skips building/sending in that case — but we + /// still cache so a later activation gets the current tree). + pub fn push(&mut self, update: TreeUpdate) { + if let Ok(mut cached) = self.tree.lock() { + *cached = Some(update.clone()); + } + self.raise(update); + } + + /// Drain pending action requests into host-level actions. The runner + /// applies each via `WidgetHostNative::apply_a11y_action`. + pub fn drain_actions(&mut self) -> Vec { + let mut out = Vec::new(); + if let Ok(mut q) = self.queue.lock() { + while let Some(req) = q.pop_front() { + let is_focus = matches!(req.action, accesskit::Action::Focus); + // Only Focus / Click / Default map to host state today. + if matches!( + req.action, + accesskit::Action::Focus | accesskit::Action::Click + ) { + out.push(A11yAction { + target: req.target_node.0, + is_focus, + }); + } + } + } + out + } + + // --- platform-specific glue ------------------------------------ + + #[cfg(target_os = "macos")] + fn build_adapter( + window: &winit::window::Window, + tree: SharedTree, + queue: ActionQueue, + ) -> Option { + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let raw = window.window_handle().ok()?.as_raw(); + let RawWindowHandle::AppKit(handle) = raw else { + return None; + }; + let ns_view = handle.ns_view.as_ptr(); + let activation = CachedTreeActivation { tree }; + let action = QueueingActionHandler { queue }; + // SAFETY: `ns_view` is the live NSView of `window`, which outlives + // this adapter (the adapter is dropped with the host, before the + // window). The handle came straight from `window.window_handle()`. + let adapter = + unsafe { accesskit_macos::SubclassingAdapter::new(ns_view, activation, action) }; + Some(adapter) + } + + #[cfg(target_os = "macos")] + fn raise(&mut self, update: TreeUpdate) { + if let Some(adapter) = self.adapter.as_mut() { + if let Some(events) = adapter.update_if_active(|| update) { + events.raise(); + } + } + } + + #[cfg(target_os = "windows")] + fn build_adapter( + window: &winit::window::Window, + tree: SharedTree, + queue: ActionQueue, + ) -> Option { + use accesskit_windows::HWND; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let raw = window.window_handle().ok()?.as_raw(); + let RawWindowHandle::Win32(handle) = raw else { + return None; + }; + // raw-window-handle 0.6 exposes the HWND as a `NonZeroIsize`; + // accesskit_windows' re-exported `HWND` wraps a `*mut c_void`. + let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); + let activation = CachedTreeActivation { tree }; + let action = QueueingActionHandler { queue }; + Some(accesskit_windows::SubclassingAdapter::new( + hwnd, activation, action, + )) + } + + #[cfg(target_os = "windows")] + fn raise(&mut self, update: TreeUpdate) { + if let Some(adapter) = self.adapter.as_mut() { + if let Some(events) = adapter.update_if_active(|| update) { + events.raise(); + } + } + } + + #[cfg(target_os = "linux")] + fn build_adapter( + _window: &winit::window::Window, + tree: SharedTree, + queue: ActionQueue, + ) -> Option { + // The Unix (AT-SPI / D-Bus) adapter is windowless. It needs a + // deactivation handler in addition to activation + action; a + // no-op suffices — the cache survives, so a re-activation just + // re-publishes the current tree. + struct NoopDeactivation; + impl accesskit::DeactivationHandler for NoopDeactivation { + fn deactivate_accessibility(&mut self) {} + } + let activation = CachedTreeActivation { tree }; + let action = QueueingActionHandler { queue }; + Some(accesskit_unix::Adapter::new( + activation, + action, + NoopDeactivation, + )) + } + + #[cfg(target_os = "linux")] + fn raise(&mut self, update: TreeUpdate) { + if let Some(adapter) = self.adapter.as_mut() { + // The Unix adapter's `update_if_active` returns `()`. + adapter.update_if_active(|| update); + } + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + fn build_adapter( + _window: &winit::window::Window, + _tree: SharedTree, + _queue: ActionQueue, + ) -> Option<()> { + None + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + fn raise(&mut self, _update: TreeUpdate) {} +} diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index dc6c52837..98e26424f 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -2,7 +2,7 @@ //! `main.rs` to keep that file under the 800-line cap. use crate::{ - chat_attachment, chat_session, codegen_session, cursor_icon, design_session, + a11y, chat_attachment, chat_session, codegen_session, cursor_icon, design_session, figma_import_session, frame, git_jobs, menu, persistence, settings_io, window_state, DesktopApp, DesktopEvent, INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W, }; @@ -107,6 +107,15 @@ impl ApplicationHandler for DesktopApp { if let Some(saved) = saved_geometry.as_ref() { attrs = saved.apply_to(attrs); } + // Windows only: create the window hidden so the accessibility + // subclassing adapter (#67) can attach BEFORE the HWND is shown — + // `accesskit_windows::SubclassingAdapter::new` panics on an already + // visible window. `set_visible(true)` runs once the adapter is in. + // macOS / Linux create visible as before (their adapters don't care). + #[cfg(target_os = "windows")] + { + attrs = attrs.with_visible(false); + } let window = match event_loop.create_window(attrs) { Ok(w) => w, Err(err) => { @@ -149,6 +158,26 @@ impl ApplicationHandler for DesktopApp { self.app_menu = Some(menu::AppMenu::install(window)); } + // Build the OS accessibility bridge (#67) from the window's raw + // handle and publish the initial tree so a screen reader sees the + // editor's regions immediately (subsequent frames push fresh + // trees from `RedrawRequested`). + if let Some(window) = self.window.as_ref() { + let mut a11y = a11y::DesktopA11y::new(window); + let update = self + .host + .accessibility_tree_update(self.viewport_width, self.viewport_height); + a11y.push(update); + self.a11y = Some(a11y); + } + + // Windows: now that the a11y adapter is attached to the hidden HWND, + // reveal the window (see the `with_visible(false)` note above). + #[cfg(target_os = "windows")] + if let Some(window) = self.window.as_ref() { + window.set_visible(true); + } + // Seed the window-geometry tracking. A restored maximized // window keeps the saved *windowed* position / size so // un-maximizing later lands somewhere sensible; otherwise @@ -291,6 +320,14 @@ impl ApplicationHandler for DesktopApp { event_loop.exit(); } } + DesktopEvent::ForwardedFileReady => { + if self.drain_forwarded_files() { + self.request_redraw(true); + } + // Raise the window even for a bare ping (no path) so a second + // launch surfaces the running editor. + self.raise_window(); + } } } @@ -444,6 +481,17 @@ impl ApplicationHandler for DesktopApp { self.redraw_dirty = true; return; } + // Route any accessibility action requests (#67) from the + // screen reader back into host state before painting, so a + // Focus / activation reflects in this frame. + if let Some(a11y) = self.a11y.as_mut() { + let actions = a11y.drain_actions(); + for action in actions { + if self.host.apply_a11y_action(action.target, action.is_focus) { + self.redraw_dirty = true; + } + } + } if chat_session::drain_new_chat_request( &mut self.host, &mut self.current_chat, @@ -571,6 +619,9 @@ impl ApplicationHandler for DesktopApp { if self.drain_provider_connect() { self.redraw_dirty = true; } + if self.drain_acp_agent_connect() { + self.redraw_dirty = true; + } // Drain the background auto-update probe. if self.poll_update_probe() { self.redraw_dirty = true; @@ -645,6 +696,18 @@ impl ApplicationHandler for DesktopApp { self.dpi, ); } + // Republish the accessibility tree alongside the + // painted frame so the screen reader's view tracks + // the visible editor state (#67). `update_if_active` + // is cheap when no assistive tech is attached. + if self.a11y.is_some() { + let update = self + .host + .accessibility_tree_update(self.viewport_width, self.viewport_height); + if let Some(a11y) = self.a11y.as_mut() { + a11y.push(update); + } + } } // Chat / design / Figma-import worker active → wake // ~10 fps to pump results and animate the loading @@ -681,6 +744,7 @@ impl ApplicationHandler for DesktopApp { .as_ref() .is_some_and(crate::iconify_host::IconifyJob::is_pending) || self.provider_connect_pending() + || self.acp_agent_connect_pending() || self .git_pull_job .as_ref() @@ -1248,6 +1312,7 @@ impl DesktopApp { .as_ref() .is_some_and(crate::iconify_host::IconifyJob::is_pending) || self.provider_connect_pending() + || self.acp_agent_connect_pending() || self .git_pull_job .as_ref() diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index baac33d8f..00b389361 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -3,6 +3,8 @@ #![cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +mod a11y; +mod acp_agent_probe_host; mod ai_proxy; mod app_handler; mod chat_acp; @@ -64,10 +66,12 @@ mod provider_probe_models; mod remote_image_host; mod render_cli; mod settings_io; +mod single_instance; mod tcc_selftest; mod theme_preset_host; mod update_check; mod web_canvas_server; +mod web_chat_standard; mod web_static; mod window_state; @@ -83,10 +87,18 @@ const INITIAL_VIEWPORT_H: f32 = 900.0; #[derive(Clone, Copy, Debug)] enum DesktopEvent { McpWake, + /// A second launch forwarded a document to this instance (see + /// `single_instance`). Wakes the loop to drain the forward queue + raise + /// the window. + ForwardedFileReady, } struct DesktopApp { window: Option, + /// OS accessibility bridge (#67) — publishes the assembled + /// `accesskit::TreeUpdate` to VoiceOver / Narrator / Orca and queues + /// incoming action requests. `None` until the window is created. + a11y: Option, ctx: Option, backend: Option, host: WidgetHostNative, @@ -177,6 +189,9 @@ struct DesktopApp { remote_images: remote_image_host::RemoteImageSession, /// Cross-thread wake handle used by live MCP connection threads. mcp_wake_proxy: Option>, + /// Paths forwarded by second-launch processes (`single_instance`), + /// drained on the UI thread by `drain_forwarded_files`. + forwarded_files: single_instance::ForwardQueue, iconify_job: Option, /// The `component_browser_open` value last written to /// `uikits.json` — `drain_kit_io` rewrites the store when the live @@ -186,6 +201,9 @@ struct DesktopApp { /// Connect) — spawned from the `pending_provider_connect` /// request seam, drained by `drain_provider_connect`. provider_connect_job: Option, + /// In-flight ACP-agent connect probe (Settings → Agents → ACP + /// Connect), drained by `drain_acp_agent_connect`. + acp_agent_connect_job: Option, /// Document to open once the window is ready — set from argv by /// the file-association launch path (`openpencil-desktop X.op`). initial_file: Option, @@ -299,6 +317,7 @@ impl DesktopApp { }; Self { window: None, + a11y: None, ctx: None, backend: None, host, @@ -332,9 +351,11 @@ impl DesktopApp { image_panel: image_panel_host::ImagePanelJobs::new(), remote_images: remote_image_host::RemoteImageSession::new(), mcp_wake_proxy: None, + forwarded_files: single_instance::ForwardQueue::default(), iconify_job: None, kit_browser_open_persisted, provider_connect_job: None, + acp_agent_connect_job: None, initial_file, app_menu: None, update_probe, @@ -542,6 +563,55 @@ impl DesktopApp { } } + /// Drain documents forwarded by second-launch processes + /// (`single_instance`) and open them in this window. Cross-platform + /// analogue of `drain_opened_files` (which only covers the macOS + /// Apple-event path). Returns true when a document was opened. + fn drain_forwarded_files(&mut self) -> bool { + let paths: Vec = match self.forwarded_files.lock() { + Ok(mut queue) => queue.drain(..).collect(), + Err(_) => return false, + }; + let mut opened = false; + for path in paths { + let is_op = persistence::is_supported_document(&path); + let is_fig = persistence::is_supported_figma_import(&path); + if (!is_op && !is_fig) || !path.is_file() { + continue; + } + // Single-window editor: the first forwarded document wins, the + // rest are ignored (mirrors `drain_opened_files`). + if opened { + continue; + } + if is_fig { + figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + self.current_figma_import = Some(figma_import_session::spawn(&mut self.host, path)); + self.request_redraw(true); + opened = true; + } else if persistence::open_path( + &mut self.host, + path, + &mut self.current_path, + self.window.as_ref(), + ) { + self.mark_document_saved(); + opened = true; + } + } + opened + } + + /// Bring the editor window to the foreground — used when a second launch + /// forwards (or just pings) this instance so the user sees the document + /// surface in the running window. + fn raise_window(&self) { + if let Some(window) = self.window.as_ref() { + window.set_minimized(false); + window.focus_window(); + } + } + /// Show the save-changes prompt when the document has unsaved /// edits. Returns `true` when it is safe to close — no edits, or /// the user chose Save (which succeeded) or Don't Save — and @@ -889,6 +959,13 @@ fn main() { return; } let initial_file = initial_file_from_argv(); + // Single-instance gate: when an editor is already running, a second launch + // (e.g. a `.op` double-click on Windows / Linux) forwards its document to + // the running window and exits instead of opening a second editor. + let primary = match single_instance::acquire(initial_file.as_deref()) { + single_instance::Acquire::Forwarded => return, + single_instance::Acquire::Primary(primary) => primary, + }; let mut event_loop_builder = EventLoop::::with_user_event(); #[cfg(target_os = "macos")] { @@ -908,6 +985,11 @@ fn main() { macos_app::apply(); let mut app = DesktopApp::new(initial_file); app.mcp_wake_proxy = Some(mcp_wake_proxy); + // Start accepting forwarded opens from second launches, sharing the queue + // the UI thread drains in `drain_forwarded_files`. + let forwarded_files = single_instance::ForwardQueue::default(); + primary.spawn_listener(event_loop.create_proxy(), forwarded_files.clone()); + app.forwarded_files = forwarded_files; app.force_live_mcp_port = live_mcp_port_from_argv(); if let Err(err) = event_loop.run_app(&mut app) { eprintln!("openpencil-desktop: run_app exited with error: {err}"); diff --git a/crates/op-host-desktop/src/single_instance.rs b/crates/op-host-desktop/src/single_instance.rs new file mode 100644 index 000000000..24aef6f35 --- /dev/null +++ b/crates/op-host-desktop/src/single_instance.rs @@ -0,0 +1,248 @@ +//! Cross-platform single-instance guard + second-launch file forwarding. +//! +//! TS parity: `apps/desktop/main.ts` uses Electron's +//! `requestSingleInstanceLock()` + the `second-instance` event to forward a +//! double-clicked document path into the already-running window. Upstream +//! winit (and the `casement` fork) has no equivalent, and on Windows / Linux +//! the OS file-association launches a fresh process per double-click. Without +//! this guard a second `.op` open would spawn a second editor window instead +//! of surfacing the file in the running one. +//! +//! Mechanism — deliberately free of platform `#[cfg]` and of any new +//! dependency: +//! +//! * The PRIMARY instance binds a fixed loopback TCP port. A successful bind +//! IS the mutual-exclusion primitive — only one process can hold a given +//! `127.0.0.1:` at a time, identically on macOS / Windows / Linux. +//! * A SECONDARY instance (bind fails with `AddrInUse`) connects to that port, +//! sends a one-line `MAGIC\t` frame, and exits — the running editor +//! opens the file. +//! * If the bind fails for an UNRELATED reason (the port is held by some other +//! app), the handshake won't echo `OK`, so the secondary falls back to +//! launching its own window. Single-instance silently degrades; the app +//! still works. +//! +//! Trust model: the listener is loopback-only and the protocol is gated on a +//! magic header, so a stray local connection can't drive an open. Like +//! Electron/Tauri's default single-instance IPC, same-user loopback peers are +//! trusted (a same-user process could open the file directly anyway). macOS +//! app bundles are already single-instance via LaunchServices + Apple events +//! (`drain_opened_files`); this guard additionally covers the raw binary and +//! the Windows / Linux file-association path. + +use std::collections::VecDeque; +use std::io::{BufRead, BufReader, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use winit::event_loop::EventLoopProxy; + +use crate::DesktopEvent; + +/// App-specific high loopback port. Distinct from the live-MCP default (3100) +/// and from common dev-server ports so an unrelated listener rarely collides. +const INSTANCE_PORT: u16 = 47823; + +/// Protocol header. A peer that doesn't open with this isn't our editor, so the +/// secondary won't treat a stray listener as a forward target. +const MAGIC: &str = "OPENPENCIL-OPEN"; + +/// Shared queue of paths forwarded by secondary launches, drained on the UI +/// thread once the proxy wakes the event loop. +pub type ForwardQueue = Arc>>; + +/// Outcome of the single-instance gate. +pub enum Acquire { + /// This process owns the instance. Hold the returned handle and, once the + /// event loop exists, call [`PrimaryHandle::spawn_listener`]. + Primary(PrimaryHandle), + /// A running instance accepted the forwarded file; the caller should exit. + Forwarded, +} + +/// Held by the primary process. Carries the bound listener (when the bind +/// succeeded) so the accept loop can start after the `EventLoopProxy` exists. +pub struct PrimaryHandle { + listener: Option, +} + +/// Run the gate. `initial_file` is the document parsed from argv, if any. +pub fn acquire(initial_file: Option<&Path>) -> Acquire { + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, INSTANCE_PORT)); + match TcpListener::bind(addr) { + Ok(listener) => Acquire::Primary(PrimaryHandle { + listener: Some(listener), + }), + Err(_) => { + // Port busy (or bind failed for another reason): try to hand the + // file to whoever holds it. + if forward_to_primary(addr, initial_file) { + return Acquire::Forwarded; + } + // No editor answered. The holder may have just exited (the bind + // failed, then the port freed before our connect), or the bind + // failed transiently. Try ONCE more to become the real primary + // before degrading — this closes the bind/connect race window so + // we don't run as an unguarded "primary" that can't receive + // forwards while another process owns the port. + match TcpListener::bind(addr) { + Ok(listener) => Acquire::Primary(PrimaryHandle { + listener: Some(listener), + }), + // Still can't bind: launch normally but without a listener — + // best-effort, single-instance silently degrades this run. + Err(_) => Acquire::Primary(PrimaryHandle { listener: None }), + } + } + } +} + +/// Connect to the running primary and forward the document path. Returns true +/// only when the peer acknowledged with `OK` (confirming it is our editor). +fn forward_to_primary(addr: SocketAddr, initial_file: Option<&Path>) -> bool { + let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(500)) else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); + let path = initial_file + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + // One line: `MAGIC\t\n`. An empty path is a bare "focus me" ping. + if stream + .write_all(format!("{MAGIC}\t{path}\n").as_bytes()) + .is_err() + { + return false; + } + let _ = stream.flush(); + let mut reply = String::new(); + if BufReader::new(&mut stream).read_line(&mut reply).is_err() { + return false; + } + reply.trim() == "OK" +} + +impl PrimaryHandle { + /// Start accepting forwarded opens. Each valid frame pushes its path onto + /// `queue` and wakes the event loop via `DesktopEvent::ForwardedFileReady` + /// so the UI thread can open it + raise the window. No-op when the bind + /// failed (degraded single-instance — see module docs). + pub fn spawn_listener(self, proxy: EventLoopProxy, queue: ForwardQueue) { + let Some(listener) = self.listener else { + return; + }; + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + if accept_frame(stream, &queue) { + // Wake the UI thread to drain the queue + raise the window + // (true even for a bare ping with no path — the user + // double-clicked the app, so surface it). + let _ = proxy.send_event(DesktopEvent::ForwardedFileReady); + } + } + }); + } +} + +/// Read one frame, validate the magic header, enqueue any non-empty path, and +/// reply `OK` / `ERR` so the secondary can confirm it reached our editor. +/// Returns true when the frame was a valid OpenPencil request (the caller +/// should then wake the loop). Kept proxy-free so it is unit-testable without +/// a winit event loop. +fn accept_frame(mut stream: TcpStream, queue: &ForwardQueue) -> bool { + let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); + let mut line = String::new(); + let read = { + let mut reader = BufReader::new(&mut stream); + reader.read_line(&mut line) + }; + if read.is_err() { + return false; + } + let line = line.trim_end_matches(['\r', '\n']); + // Not our protocol (some other app holds the port) → reject, don't wake. + let Some((magic, path)) = line.split_once('\t') else { + let _ = stream.write_all(b"ERR\n"); + return false; + }; + if magic != MAGIC { + let _ = stream.write_all(b"ERR\n"); + return false; + } + if !path.is_empty() { + if let Ok(mut queue) = queue.lock() { + queue.push_back(PathBuf::from(path)); + } + } + let _ = stream.write_all(b"OK\n"); + let _ = stream.flush(); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + /// End-to-end of the wire protocol: a manually-bound listener (standing in + /// for the primary) + a `forward_to_primary` call (the secondary) must + /// round-trip the document path onto the shared queue. Kept off the + /// `PrimaryHandle`/`EventLoop` path so it runs on any test thread (winit + /// refuses to build an event loop off the macOS main thread). + #[test] + fn forward_protocol_round_trips_path() { + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, INSTANCE_PORT)); + let listener = match TcpListener::bind(addr) { + Ok(listener) => listener, + // Port already held (sandbox / parallel run) — skip, don't flake. + Err(_) => return, + }; + let queue: ForwardQueue = Arc::new(Mutex::new(VecDeque::new())); + let server_queue = queue.clone(); + let server = std::thread::spawn(move || { + if let Ok((stream, _)) = listener.accept() { + accept_frame(stream, &server_queue) + } else { + false + } + }); + + let path = std::env::temp_dir().join("single-instance-fixture.op"); + assert!( + forward_to_primary(addr, Some(&path)), + "secondary should forward" + ); + assert!( + server.join().unwrap(), + "accept_frame should accept a valid frame" + ); + assert_eq!( + queue.lock().unwrap().pop_front().as_deref(), + Some(path.as_path()), + ); + } + + /// A frame without the magic header (some other app on the port) must be + /// rejected and must not enqueue anything. + #[test] + fn non_magic_frame_is_rejected() { + use std::io::Write; + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("ephemeral bind"); + let addr = listener.local_addr().unwrap(); + let queue: ForwardQueue = Arc::new(Mutex::new(VecDeque::new())); + let server_queue = queue.clone(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + accept_frame(stream, &server_queue) + }); + + let mut stream = TcpStream::connect(addr).unwrap(); + stream.write_all(b"GET / HTTP/1.1\r\n").unwrap(); + stream.flush().unwrap(); + assert!(!server.join().unwrap(), "non-magic frame must be rejected"); + assert!(queue.lock().unwrap().is_empty()); + } +} diff --git a/crates/op-host-native/Cargo.toml b/crates/op-host-native/Cargo.toml index 522590d2f..b14e7bea5 100644 --- a/crates/op-host-native/Cargo.toml +++ b/crates/op-host-native/Cargo.toml @@ -16,6 +16,12 @@ path = "src/lib.rs" # all resolve through the op-editor-ui crate. op-editor-ui = { path = "../op-editor-ui" } +# Accessibility (#67): the host assembles every widget's `access_node()` +# into an `accesskit::TreeUpdate` (see `widget_host/a11y.rs`) for the +# desktop platform adapter to publish. Pinned to 0.24 to match +# op-editor-ui + the platform adapter crates' transitive accesskit. +accesskit = "0.24" + # Canvas Preview (Play) mode serializes the document to JSON before # building the jian runtime, so the saved doc is never mutated. serde_json = { workspace = true } diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 0c20f4690..767c3526b 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -34,6 +34,7 @@ use op_editor_ui::widgets::SelectionHandle; use op_editor_ui::{Rect, Theme}; +mod a11y; #[cfg(test)] mod agent_settings_acp_tests; #[cfg(test)] diff --git a/crates/op-host-native/src/widget_host/a11y.rs b/crates/op-host-native/src/widget_host/a11y.rs new file mode 100644 index 000000000..f1857f9c2 --- /dev/null +++ b/crates/op-host-native/src/widget_host/a11y.rs @@ -0,0 +1,306 @@ +//! Accessibility region tree for the native host (#67). +//! +//! Builds an `accesskit::TreeUpdate` over the SAME top-level widgets the +//! paint pass (`widget_host/paint.rs`) composes, so a screen reader sees +//! the editor's always-present regions — top bar, layer panel, toolbar, +//! canvas, property panel, chat, status bar — plus any cheap open +//! overlays, each at the rect the host painted it at. +//! +//! The actual tree shape / node-id mapping / focus resolution lives in +//! the platform-free assembler `op_editor_ui::accessibility`; this file +//! is only the host-side enumeration that pairs each widget with its +//! placement rect. Keeping the geometry here (reusing the same +//! `canvas_region` / `*_rect` helpers paint uses) means the a11y tree and +//! the painted frame never drift. + +use super::helpers::{TOOLBAR_INSET_X, TOOLBAR_INSET_Y}; +use super::WidgetHostNative; +use op_editor_ui::accessibility::{assemble_tree_update, PlacedWidget}; +use op_editor_ui::widgets::{ + AIChatPlaceholder, CanvasViewport, LayerPanel, LayoutCx, PropertyPanel, StatusBar, Toolbar, + TopBar, Widget, WidgetId, ROOT_WIDGET_ID, TOOLBAR_WIDTH, TOP_BAR_HEIGHT, +}; +use op_editor_ui::{Point2D, Rect}; + +impl WidgetHostNative { + /// Assemble the accessibility tree for the current editor frame. + /// + /// Hosts call this on the same cadence they paint (initial publish + + /// every dirty frame); the assembler suppresses no-op events on the + /// adapter side. The widget set mirrors `paint.rs`'s always-present + /// regions; transient overlays (pickers, modals, context menus) are + /// intentionally omitted for v1 — they come and go every frame and + /// their `access_node()`s are not yet richly labelled, so adding them + /// would churn the tree without improving navigability. + /// + /// Takes `&mut self` because the canvas region reads the + /// layout-resolved scene, which `refresh_layout_scene` lazily rebuilds + /// when the editor state is dirty — same contract as `paint`. + pub fn accessibility_tree_update( + &mut self, + viewport_width: f32, + viewport_height: f32, + ) -> accesskit::TreeUpdate { + // Keep the canvas scene in sync with editor state (cheap no-op + // when not dirty) so the CanvasViewport widget is consistent + // with what paint draws. + self.refresh_layout_scene(); + + let window_bounds = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(viewport_width, viewport_height), + }; + + let ui = &self.editor_state.editor_ui; + let dpi = self.dpi_scale_hint(); + + // 1. TopBar — full-width top strip. + let top_bar = TopBar::for_editor_ui(ui); + let top_bar_rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(viewport_width, TOP_BAR_HEIGHT), + }; + + // 2. LayerPanel — left rail, only when the sidebar is open. + let layer_panel = LayerPanel::from_editor(&self.editor_state); + let layer_panel_rect = Rect { + origin: Point2D::new(0.0, TOP_BAR_HEIGHT), + size: Point2D::new( + ui.layer_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + + // 3. CanvasViewport — middle band (sidebar/right-rail aware). + let (canvas_left, _canvas_y, canvas_w, canvas_h) = + self.canvas_region(viewport_width, viewport_height); + let canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene); + let canvas_rect = Rect { + origin: Point2D::new(canvas_left, TOP_BAR_HEIGHT), + size: Point2D::new(canvas_w, canvas_h), + }; + + // 4. PropertyPanel — right rail, only with a selection. + let property_panel = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms); + let property_panel_width = ui.property_panel_width; + let property_rect = Rect { + origin: Point2D::new(viewport_width - property_panel_width, TOP_BAR_HEIGHT), + size: Point2D::new( + property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + + // 5. Toolbar — floating vertical column over the canvas. + let toolbar = Toolbar::for_editor(&self.editor_state); + let toolbar_h = toolbar + .layout(&LayoutCx { + available_width: TOOLBAR_WIDTH, + dpi, + }) + .rect + .size + .y; + let toolbar_rect = Rect { + origin: Point2D::new( + canvas_left + TOOLBAR_INSET_X, + TOP_BAR_HEIGHT + TOOLBAR_INSET_Y, + ), + size: Point2D::new(TOOLBAR_WIDTH, toolbar_h), + }; + let toolbar_visible = canvas_w > TOOLBAR_WIDTH + TOOLBAR_INSET_X * 2.0; + + // 6. AIChatPlaceholder — floating chat panel. + let chat = AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms); + let chat_rect = self.ai_chat_rect(viewport_width, viewport_height); + + // 7. StatusBar — floating bottom-right zoom pill. + let status = StatusBar::for_editor(&self.editor_state); + let status_rect = self.status_bar_rect(viewport_width, viewport_height); + + // Assemble the ordered, present set. Order = reading order. + let mut placed: Vec> = Vec::with_capacity(8); + placed.push(PlacedWidget::new(&top_bar, top_bar_rect)); + if ui.sidebar_open { + placed.push(PlacedWidget::new(&layer_panel, layer_panel_rect)); + } + if canvas_w > 0.0 && canvas_h > 0.0 { + placed.push(PlacedWidget::new(&canvas, canvas_rect)); + } + if let Some(panel) = property_panel.as_ref() { + placed.push(PlacedWidget::new(panel, property_rect)); + } + if toolbar_visible { + placed.push(PlacedWidget::new(&toolbar, toolbar_rect)); + } + if let Some(rect) = chat_rect { + placed.push(PlacedWidget::new(&chat, rect)); + } + if let Some(rect) = status_rect { + placed.push(PlacedWidget::new(&status, rect)); + } + + let focus = self.accessibility_focus_target(canvas_w, canvas_h, property_panel.is_some()); + + assemble_tree_update(window_bounds, &placed, focus) + } + + /// Pick a sensible default focus target for the a11y tree. + /// + /// Order: focused chat input → property panel (when an editable + /// selection is up) → canvas (the editor's primary work surface) → + /// top bar → root. The chosen id must be a region actually present + /// this frame, which the assembler re-checks before emitting. + fn accessibility_focus_target( + &self, + canvas_w: f32, + canvas_h: f32, + property_panel_present: bool, + ) -> WidgetId { + if self.editor_state.chat.focused { + return WidgetId::new(AI_CHAT_WIDGET_ID); + } + if property_panel_present && self.editor_state.ui.property_focus.is_some() { + return WidgetId::new(PROPERTY_PANEL_WIDGET_ID); + } + if canvas_w > 0.0 && canvas_h > 0.0 { + return WidgetId::new(CANVAS_WIDGET_ID); + } + ROOT_WIDGET_ID + } + + /// DPI scale used for the toolbar layout pass. The toolbar layout is + /// dpi-independent (fixed button metrics), so a 1.0 fallback is + /// exact; the real value is only threaded for parity with paint. + fn dpi_scale_hint(&self) -> f32 { + 1.0 + } + + /// Route an accesskit action targeting a known editor region back + /// into host state. Returns `true` when the action changed state (so + /// the runner repaints + re-publishes the tree). Mirrors the web + /// `a11y_bridge` action handlers. + /// + /// `target` is the raw `accesskit::NodeId.0` (== `WidgetId.0`), and + /// `is_focus` distinguishes a `Focus` request from a `Click` / + /// `Default` activation. v1 handles the two regions the web bridge + /// covered — focusing the chat input and activating it — plus + /// blurring the chat when focus moves to the canvas / a panel. + pub fn apply_a11y_action(&mut self, target: u64, is_focus: bool) -> bool { + match target { + // AIChat panel — Focus or Click/Default both focus + ready + // the chat input (TS click.rs `AIChatHit::FocusInput`). + AI_CHAT_WIDGET_ID => { + let now = self.now_ms; + self.editor_state.chat.focus_input_at_end(now); + self.editor_state.chat.transcript_selection = None; + self.mark_editor_state_dirty(); + true + } + // Canvas / Toolbar / Property panel — moving a11y focus off + // the chat blurs the chat input so caret + send routing + // follow the screen reader's focus. Only meaningful when the + // chat currently holds focus. + CANVAS_WIDGET_ID | TOOLBAR_WIDGET_ID | PROPERTY_PANEL_WIDGET_ID if is_focus => { + if self.editor_state.chat.focused { + self.editor_state.chat.focused = false; + self.mark_editor_state_dirty(); + true + } else { + false + } + } + _ => false, + } + } +} + +// Stable widget ids of the always-present regions, mirrored from each +// widget's constructor (`WidgetId::new(..)`). Used for focus targeting +// without constructing the widget twice. +const AI_CHAT_WIDGET_ID: u64 = 7000; +const PROPERTY_PANEL_WIDGET_ID: u64 = 2000; +const CANVAS_WIDGET_ID: u64 = 4000; +const TOOLBAR_WIDGET_ID: u64 = 3000; + +#[cfg(test)] +mod tests { + use super::*; + use op_editor_ui::accessibility::node_id; + + fn host() -> WidgetHostNative { + WidgetHostNative::new() + } + + #[test] + fn tree_includes_always_present_regions() { + let mut h = host(); + let update = h.accessibility_tree_update(1280.0, 800.0); + // Root + at least: top bar, layer panel, canvas, toolbar, + // chat, status bar (property panel only with a selection). + let ids: Vec<_> = update.nodes.iter().map(|(id, _)| *id).collect(); + assert!(ids.contains(&node_id(ROOT_WIDGET_ID))); + assert!(ids.contains(&node_id(WidgetId::new(5000))), "top bar"); + assert!(ids.contains(&node_id(WidgetId::new(4000))), "canvas"); + assert!(ids.contains(&node_id(WidgetId::new(7000))), "chat"); + // Root advertises every emitted child. + let (_, root) = &update.nodes[0]; + for child in root.children() { + assert!( + update.nodes.iter().any(|(id, _)| id == child), + "root child {child:?} missing a node" + ); + } + } + + #[test] + fn focus_defaults_to_canvas() { + let mut h = host(); + let update = h.accessibility_tree_update(1280.0, 800.0); + assert_eq!(update.focus, node_id(WidgetId::new(CANVAS_WIDGET_ID))); + } + + #[test] + fn focused_chat_input_takes_focus() { + let mut h = host(); + h.editor_state_mut().chat.focused = true; + let update = h.accessibility_tree_update(1280.0, 800.0); + assert_eq!(update.focus, node_id(WidgetId::new(AI_CHAT_WIDGET_ID))); + } + + #[test] + fn a11y_action_on_chat_focuses_input() { + let mut h = host(); + h.set_now_ms(1234); + let changed = h.apply_a11y_action(AI_CHAT_WIDGET_ID, true); + assert!(changed); + assert!(h.editor_state().chat.focused); + } + + #[test] + fn a11y_focus_on_canvas_blurs_chat() { + let mut h = host(); + h.editor_state_mut().chat.focused = true; + let changed = h.apply_a11y_action(CANVAS_WIDGET_ID, true); + assert!(changed); + assert!(!h.editor_state().chat.focused); + } + + #[test] + fn a11y_action_on_unknown_region_is_noop() { + let mut h = host(); + assert!(!h.apply_a11y_action(99999, true)); + } + + #[test] + fn collapsed_sidebar_drops_layer_panel_region() { + let mut h = host(); + h.editor_state_mut().editor_ui.sidebar_open = false; + let update = h.accessibility_tree_update(1280.0, 800.0); + let ids: Vec<_> = update.nodes.iter().map(|(id, _)| *id).collect(); + assert!( + !ids.contains(&node_id(WidgetId::new(1000))), + "layer panel hidden" + ); + } +} diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index c9bc1dd4e..e04189cc4 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -1479,6 +1479,18 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + // Escape closes an open layer/page right-click context menu + // (layer-context-menu.tsx:101 — keydown Escape → onClose). + if self + .editor_state + .editor_ui + .layer_context_menu + .take() + .is_some() + { + self.mark_dirty(); + return true; + } if self.editor_state.rename_cancel() { self.mark_dirty(); return true; diff --git a/crates/op-host-web/src/a11y_dom.rs b/crates/op-host-web/src/a11y_dom.rs new file mode 100644 index 000000000..81e739345 --- /dev/null +++ b/crates/op-host-web/src/a11y_dom.rs @@ -0,0 +1,350 @@ +//! Hidden ARIA DOM mirror for the CanvasKit web shell (#57). +//! +//! The editor renders to an opaque ``, so a screen reader sees +//! nothing. This module builds a parallel, visually-hidden DOM tree — +//! one focusable element per accessibility node — from the same +//! `accesskit::TreeUpdate` the platform-free assembler +//! (`op_editor_ui::accessibility`) produces. Each mirror element carries +//! an ARIA `role`, an `aria-label`, and a `tabindex` so VoiceOver / +//! Narrator / Orca can read + tab through the editor's regions. +//! +//! The mirror is NOT `display:none` (which removes it from the +//! accessibility tree); it uses the standard `.sr-only` clip technique so +//! it stays screen-reader-visible while being invisible + zero-area on +//! screen. +//! +//! ## Why this module is platform-only (no `WidgetHost`) +//! +//! It depends solely on `accesskit` + `web-sys`, so it compile-checks +//! under BOTH the production `canvaskit` feature and the wasm32-clean +//! `web` stub feature. The host-side enumeration that *produces* the +//! `TreeUpdate` (and routes DOM events back into editor state) lives in +//! `widget_host/a11y_bridge.rs`, which is `canvaskit`-only because it +//! pulls `op-editor-core`. +//! +//! ## Update strategy (v1) +//! +//! Rebuild-on-change: each refresh clears the container and re-creates one +//! element per node. The tree is tiny (~8 always-present regions), so a +//! full rebuild per dirty frame is cheaper than diffing and keeps the +//! NodeId→element mapping trivially consistent. The map is retained so the +//! event-routing layer can resolve a focused/clicked element back to its +//! `accesskit::NodeId`. +//! +//! Under the `web` stub feature the mirror compiles for CI coverage but +//! has no caller (the host enumeration is `canvaskit`-only), so its public +//! surface reads as dead code there — silenced below. +#![cfg_attr(not(feature = "canvaskit"), allow(dead_code))] + +use std::collections::HashMap; + +use accesskit::{NodeId, Role, TreeUpdate}; +use wasm_bindgen::JsCast; +use web_sys::{Document, Element, HtmlElement}; + +/// `data-*` attribute holding the stable `accesskit::NodeId` of a mirror +/// element. The event-routing layer reads it back to map a DOM event to a +/// host action. +pub(crate) const NODE_ID_ATTR: &str = "data-op-a11y-id"; + +/// The id of the mirror container element appended next to the canvas. +const CONTAINER_ID: &str = "op-a11y-mirror"; + +/// Standard screen-reader-only style: zero-area, clipped, off-screen, but +/// NOT `display:none` (which would drop it from the accessibility tree). +/// Applied to the container; children inherit visibility via the same +/// clip on the container box. +const SR_ONLY_STYLE: &str = "position:absolute;width:1px;height:1px;\ +padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);\ +clip-path:inset(50%);white-space:nowrap;border:0;left:0;top:0;"; + +/// The hidden ARIA DOM mirror. Owns the container element and the live +/// `NodeId → element` map rebuilt on every `update`. +pub(crate) struct A11yDomMirror { + document: Document, + container: HtmlElement, + /// NodeId → focusable mirror element. Rebuilt each `update`; retained + /// so event routing can resolve a DOM target back to its NodeId. + elements: HashMap, + /// Hash of the last tree we rendered. The CanvasKit mount calls `update` + /// after EVERY repaint (caret blink, hover…); skipping the rebuild when + /// the tree is unchanged avoids destroying + recreating the focused + /// element each frame (which would churn screen-reader / keyboard focus). + /// `None` until the first build. + signature: Option, +} + +impl A11yDomMirror { + /// Create the mirror container and append it as a sibling of `canvas` + /// (or, failing that, to ``). Returns `None` only if the DOM is + /// unreachable (non-browser host). + pub(crate) fn create(canvas: &web_sys::HtmlCanvasElement) -> Option { + let document = canvas + .owner_document() + .or_else(|| web_sys::window().and_then(|w| w.document()))?; + + // Reuse an existing container across re-mounts so we don't leak a + // second hidden tree onto the page. + let container: HtmlElement = match document.get_element_by_id(CONTAINER_ID) { + Some(el) => el.dyn_into::().ok()?, + None => { + let el = document.create_element("div").ok()?; + el.set_id(CONTAINER_ID); + let el: HtmlElement = el.dyn_into::().ok()?; + let _ = el.set_attribute("style", SR_ONLY_STYLE); + // `role=application` tells the screen reader this subtree is + // an interactive app surface, not a document to be read + // linearly — so arrow keys reach the canvas shortcuts. + let _ = el.set_attribute("role", "application"); + let _ = el.set_attribute("aria-label", "OpenPencil editor"); + // Insert as a sibling right after the canvas when possible + // (keeps it adjacent in source order); else append to body. + if let Some(parent) = canvas.parent_node() { + let _ = parent.append_child(&el); + } else if let Some(body) = document.body() { + let _ = body.append_child(&el); + } + el + } + }; + + Some(Self { + document, + container, + elements: HashMap::new(), + signature: None, + }) + } + + /// Rebuild the mirror from a freshly assembled `TreeUpdate`. + /// + /// Clears the container and re-creates one focusable element per node + /// (skipping the synthetic root `Window`, which the container already + /// represents as `role=application`). The map is repopulated so the + /// event-routing layer can resolve targets. + pub(crate) fn update(&mut self, tree: &TreeUpdate) { + // Skip the rebuild entirely when the tree is unchanged — the mount + // calls this every repaint, and a full rebuild would destroy the + // focused element each frame, churning focus (see `signature`). + let signature = tree_signature(tree); + if self.signature == Some(signature) { + return; + } + self.signature = Some(signature); + + // Remember whether focus currently lives inside the mirror, so we can + // restore it after the rebuild without stealing focus from elsewhere + // on the page. + let focus_was_in_mirror = self + .document + .active_element() + .map(|active| self.container.contains(active.dyn_ref::())) + .unwrap_or(false); + + // Clear previous mirror. + self.container.set_inner_html(""); + self.elements.clear(); + + // The first node is the root host frame (`ROOT_WIDGET_ID`, + // Role::Window) — the container stands in for it, so mirror only + // its descendants. Falling back to mirroring all nodes is safe if + // the assembler ever changes the ordering. + let root_id = tree.tree.as_ref().map(|t| t.root); + + for (id, node) in &tree.nodes { + if Some(*id) == root_id { + continue; + } + if let Some(el) = self.build_node_element(*id, node) { + let _ = self.container.append_child(&el); + self.elements.insert(*id, el); + } + } + + // Restore focus to the tree's focus target if focus had been in the + // mirror — otherwise the rebuild silently drops it. + if focus_was_in_mirror { + if let Some(el) = self.elements.get(&tree.focus) { + let _ = el.focus(); + } + } + } + + /// Build one focusable mirror element for an accessibility node. + fn build_node_element(&self, id: NodeId, node: &accesskit::Node) -> Option { + let el = self.document.create_element("div").ok()?; + let el: HtmlElement = el.dyn_into::().ok()?; + + let _ = el.set_attribute("role", aria_role_for(node.role())); + // Label precedence: explicit value (e.g. property panel kind) wins + // over the static region label, mirroring how a sighted user reads + // the selected node's kind in the property panel header. + let label = node.value().or_else(|| node.label()).unwrap_or(""); + if !label.is_empty() { + let _ = el.set_attribute("aria-label", label); + } + // Every mirror node is tab-focusable so a keyboard / screen-reader + // user can move through the editor's regions in reading order. + let _ = el.set_attribute("tabindex", "0"); + let _ = el.set_attribute(NODE_ID_ATTR, &id.0.to_string()); + + Some(el) + } + + /// The mirror container element — event-routing attaches delegated + /// `focus` / `click` listeners here. + pub(crate) fn container(&self) -> &HtmlElement { + &self.container + } + + /// Resolve a DOM `EventTarget` (the focused / clicked element) back to + /// its `accesskit::NodeId` by reading the `data-*` id attribute. + /// Returns `None` for targets outside the mirror. + pub(crate) fn node_id_for_target(target: &Element) -> Option { + let raw = target.get_attribute(NODE_ID_ATTR)?; + raw.parse::().ok().map(NodeId) + } +} + +/// Map an `accesskit::Role` to the closest standard ARIA role token. +/// +/// Only the roles the editor's always-present regions actually emit are +/// mapped to specific ARIA roles; everything else falls back to `group` +/// (a neutral, valid landmark-less container) so an unmapped role never +/// produces an invalid `role=` attribute. +/// +/// | accesskit Role | region | ARIA role | +/// | ------------------ | ----------------- | ------------- | +/// | `Window` | host root frame | `application` | +/// | `Header` | TopBar | `banner` | +/// | `Tree` | LayerPanel | `tree` | +/// | `Canvas` | CanvasViewport | `img` | +/// | `Toolbar` | Toolbar | `toolbar` | +/// | `Group` | PropertyPanel / | `group` | +/// | | AIChat / StatusBar| | +/// | `GenericContainer` | misc container | `group` | +/// | `Dialog` | modal overlay | `dialog` | +/// | `ListBox` | dropdown list | `listbox` | +/// | `Menu` | context menu | `menu` | +fn aria_role_for(role: Role) -> &'static str { + match role { + // The host root frame; the container already carries this, but map + // it for completeness if a caller mirrors the root directly. + Role::Window => "application", + // The TopBar is the editor's banner region. + Role::Header => "banner", + Role::Tree => "tree", + // A canvas has no DOM-native semantic; `img` with a label is the + // conventional way to expose an opaque graphic to a screen reader. + Role::Canvas => "img", + Role::Toolbar => "toolbar", + Role::Dialog => "dialog", + Role::ListBox => "listbox", + Role::Menu => "menu", + // PropertyPanel / AIChat / StatusBar all advertise `Group`; a plain + // labelled `group` is the right neutral container. + Role::Group | Role::GenericContainer => "group", + // Any role not surfaced by the v1 region set: a valid neutral + // container so the attribute is never malformed. + _ => "group", + } +} + +/// Hash of the parts of a tree the mirror renders (node ids + roles + +/// labels/values + focus). Used to skip a DOM rebuild when the tree is +/// unchanged between repaints, which keeps screen-reader focus stable. +/// +/// Per-node sub-hashes are combined with XOR so the signature depends only on +/// the SET of nodes + their content + focus, NOT on the `nodes` vec iteration +/// order — a future reorder (or a map-backed source) can't spuriously force a +/// rebuild. Node ids are unique, so distinct nodes never cancel. +fn tree_signature(tree: &TreeUpdate) -> u64 { + use std::hash::{Hash, Hasher}; + + fn node_hash(id: NodeId, node: &accesskit::Node) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + id.0.hash(&mut h); + // `Role` has no `Hash` impl; its `Debug` form is stable + cheap for + // the ~8-node region tree. + format!("{:?}", node.role()).hash(&mut h); + node.label().unwrap_or("").hash(&mut h); + node.value().unwrap_or("").hash(&mut h); + h.finish() + } + + let mut acc: u64 = 0; + for (id, node) in &tree.nodes { + acc ^= node_hash(*id, node); + } + let mut fh = std::collections::hash_map::DefaultHasher::new(); + tree.focus.0.hash(&mut fh); + acc ^ fh.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signature_is_stable_and_label_sensitive() { + fn node(role: Role, label: &str) -> accesskit::Node { + let mut n = accesskit::Node::new(role); + n.set_label(label); + n + } + let mk = |label: &str| TreeUpdate { + nodes: vec![(NodeId(1), node(Role::Toolbar, label))], + tree: Some(accesskit::Tree::new(NodeId(0))), + tree_id: accesskit::TreeId::ROOT, + focus: NodeId(1), + }; + // Same tree → same signature (no rebuild); changed label → different. + assert_eq!(tree_signature(&mk("A")), tree_signature(&mk("A"))); + assert_ne!(tree_signature(&mk("A")), tree_signature(&mk("B"))); + } + + #[test] + fn signature_is_independent_of_node_order() { + fn node(role: Role, label: &str) -> accesskit::Node { + let mut n = accesskit::Node::new(role); + n.set_label(label); + n + } + let a = (NodeId(1), node(Role::Toolbar, "Toolbar")); + let b = (NodeId(2), node(Role::Tree, "Layers")); + let forward = TreeUpdate { + nodes: vec![a.clone(), b.clone()], + tree: Some(accesskit::Tree::new(NodeId(0))), + tree_id: accesskit::TreeId::ROOT, + focus: NodeId(1), + }; + let reversed = TreeUpdate { + nodes: vec![b, a], + tree: Some(accesskit::Tree::new(NodeId(0))), + tree_id: accesskit::TreeId::ROOT, + focus: NodeId(1), + }; + // Same node set + focus, different vec order → identical signature. + assert_eq!(tree_signature(&forward), tree_signature(&reversed)); + } + + #[test] + fn known_roles_map_to_expected_aria_tokens() { + assert_eq!(aria_role_for(Role::Window), "application"); + assert_eq!(aria_role_for(Role::Header), "banner"); + assert_eq!(aria_role_for(Role::Tree), "tree"); + assert_eq!(aria_role_for(Role::Canvas), "img"); + assert_eq!(aria_role_for(Role::Toolbar), "toolbar"); + assert_eq!(aria_role_for(Role::Group), "group"); + assert_eq!(aria_role_for(Role::GenericContainer), "group"); + assert_eq!(aria_role_for(Role::Dialog), "dialog"); + assert_eq!(aria_role_for(Role::ListBox), "listbox"); + assert_eq!(aria_role_for(Role::Menu), "menu"); + } + + #[test] + fn unmapped_role_falls_back_to_group() { + // A role outside the v1 region set must still yield a valid token. + assert_eq!(aria_role_for(Role::Button), "group"); + } +} diff --git a/crates/op-host-web/src/canvaskit.rs b/crates/op-host-web/src/canvaskit.rs index d51958b41..c9bee5405 100644 --- a/crates/op-host-web/src/canvaskit.rs +++ b/crates/op-host-web/src/canvaskit.rs @@ -128,6 +128,79 @@ extern "C" { b: f32, a: f32, ); + #[wasm_bindgen(method, js_name = fillSvgPathInRect)] + fn fill_svg_path_in_rect( + this: &OpCk, + d: &str, + x: f32, + y: f32, + w: f32, + h: f32, + even_odd: bool, + r: f32, + g: f32, + b: f32, + a: f32, + ); + #[wasm_bindgen(method, js_name = strokeSvgPathInRect)] + fn stroke_svg_path_in_rect( + this: &OpCk, + d: &str, + x: f32, + y: f32, + w: f32, + h: f32, + r: f32, + g: f32, + b: f32, + a: f32, + sw: f32, + ); + #[wasm_bindgen(method, js_name = fillSvgPathInRectLinearGradient)] + fn fill_svg_path_in_rect_linear_gradient( + this: &OpCk, + d: &str, + x: f32, + y: f32, + w: f32, + h: f32, + even_odd: bool, + stops: &[f32], + angle_deg: f32, + opacity: f32, + ); + #[wasm_bindgen(method, js_name = fillSvgPathInRectRadialGradient)] + fn fill_svg_path_in_rect_radial_gradient( + this: &OpCk, + d: &str, + x: f32, + y: f32, + w: f32, + h: f32, + even_odd: bool, + stops: &[f32], + cx_frac: f32, + cy_frac: f32, + radius_frac: f32, + opacity: f32, + ); + #[wasm_bindgen(method, js_name = fillInnerShadowSvgPath)] + fn fill_inner_shadow_svg_path( + this: &OpCk, + d: &str, + x: f32, + y: f32, + w: f32, + h: f32, + even_odd: bool, + offset_x: f32, + offset_y: f32, + blur: f32, + r: f32, + g: f32, + b: f32, + a: f32, + ); #[wasm_bindgen(method, js_name = drawText)] fn draw_text( this: &OpCk, @@ -144,6 +217,8 @@ extern "C" { ); #[wasm_bindgen(method, js_name = measureText)] fn measure_text(this: &OpCk, t: &str, sz: f32) -> f32; + #[wasm_bindgen(method, js_name = registerSystemFont)] + fn register_system_font(this: &OpCk, family: &str, bytes: &[u8]) -> bool; #[wasm_bindgen(method, js_name = clipRect)] fn clip_rect(this: &OpCk, x: f32, y: f32, w: f32, h: f32); #[wasm_bindgen(method, js_name = clipRoundRect)] @@ -169,6 +244,18 @@ const NOTO_CJK_SUBSET: &[u8] = include_bytes!("../assets/NotoSansSC-OpenPencilSu const NOTO_EMOJI_SUBSET: &[u8] = include_bytes!("../assets/NotoColorEmoji-OpenPencilSubset.ttf"); const STEADY_CANVAS_PIXEL_BUDGET: f32 = 4_000_000.0; +fn flatten_gradient_stops(stops: &[(f32, Color)]) -> Vec { + let mut flat = Vec::with_capacity(stops.len() * 5); + for (offset, color) in stops { + flat.extend([*offset, color.r, color.g, color.b, color.a]); + } + flat +} + +fn svg_path_even_odd(d: &str) -> bool { + d.matches(['Z', 'z']).count() > 1 +} + /// `RenderBackend` over CanvasKit. Paints in logical (CSS) pixels; `begin_frame` /// applies the device-pixel-ratio so output matches the native backend. pub struct CanvasKitBackend { @@ -321,7 +408,7 @@ impl RenderBackend for CanvasKitBackend { ); } fn fill_svg_path(&mut self, d: &str, top_left: Point2D, size: f32, viewbox: f32, color: Color) { - let even_odd = d.matches(['Z', 'z']).count() > 1; + let even_odd = svg_path_even_odd(d); self.ck.fill_svg_path( d, top_left.x, @@ -334,6 +421,112 @@ impl RenderBackend for CanvasKitBackend { color.a, ); } + fn fill_svg_path_in_rect(&mut self, d: &str, rect: Rect, color: Color) { + let even_odd = svg_path_even_odd(d); + self.ck.fill_svg_path_in_rect( + d, + rect.origin.x, + rect.origin.y, + rect.size.x, + rect.size.y, + even_odd, + color.r, + color.g, + color.b, + color.a, + ); + } + fn stroke_svg_path_in_rect(&mut self, d: &str, rect: Rect, color: Color, width: f32) { + self.ck.stroke_svg_path_in_rect( + d, + rect.origin.x, + rect.origin.y, + rect.size.x, + rect.size.y, + color.r, + color.g, + color.b, + color.a, + width, + ); + } + fn fill_svg_path_in_rect_linear_gradient( + &mut self, + d: &str, + rect: Rect, + stops: &[(f32, Color)], + angle_deg: f32, + opacity: f32, + ) { + if stops.is_empty() { + return; + } + let flat = flatten_gradient_stops(stops); + self.ck.fill_svg_path_in_rect_linear_gradient( + d, + rect.origin.x, + rect.origin.y, + rect.size.x, + rect.size.y, + svg_path_even_odd(d), + &flat, + angle_deg, + opacity, + ); + } + fn fill_svg_path_in_rect_radial_gradient( + &mut self, + d: &str, + rect: Rect, + stops: &[(f32, Color)], + cx_frac: f32, + cy_frac: f32, + radius_frac: f32, + opacity: f32, + ) { + if stops.is_empty() { + return; + } + let flat = flatten_gradient_stops(stops); + self.ck.fill_svg_path_in_rect_radial_gradient( + d, + rect.origin.x, + rect.origin.y, + rect.size.x, + rect.size.y, + svg_path_even_odd(d), + &flat, + cx_frac, + cy_frac, + radius_frac, + opacity, + ); + } + fn fill_inner_shadow_svg_path( + &mut self, + d: &str, + rect: Rect, + offset_x: f32, + offset_y: f32, + blur: f32, + color: Color, + ) { + self.ck.fill_inner_shadow_svg_path( + d, + rect.origin.x, + rect.origin.y, + rect.size.x, + rect.size.y, + svg_path_even_odd(d), + offset_x, + offset_y, + blur, + color.r, + color.g, + color.b, + color.a, + ); + } fn draw_text(&mut self, layout: &TextLayout, origin: Point2D) { let italic = layout.italic(); @@ -425,6 +618,10 @@ struct CkInner { backend: CanvasKitBackend, host: crate::widget_host::WidgetHost, canvas: web_sys::HtmlCanvasElement, + /// Hidden ARIA DOM mirror (#57) — kept in sync after every paint so a + /// screen reader can read the opaque CanvasKit surface. `None` only if + /// the DOM container couldn't be created (non-browser host). + a11y: Option, } impl CkInner { @@ -433,6 +630,19 @@ impl CkInner { self.backend.begin_frame(); self.host.paint_dyn(&mut self.backend, w, h); self.backend.end_frame(); + self.sync_a11y(); + } + + /// Rebuild the hidden ARIA DOM mirror from a freshly assembled tree. + /// Called after each paint so the mirror tracks the painted frame + /// (cheap: ~8 always-present region nodes). A diff-or-rebuild refinement + /// can replace the full rebuild later; v1 rebuilds. + fn sync_a11y(&mut self) { + if let Some(mirror) = self.a11y.as_mut() { + let (w, h) = self.backend.logical_size(); + let tree = self.host.accessibility_tree_update(w, h); + mirror.update(&tree); + } } fn resize_to_window(&mut self, window: &web_sys::Window) -> Result { @@ -478,6 +688,18 @@ impl CkInner { } Ok(false) } + + fn event_offset_to_logical(&self, offset_x: f32, offset_y: f32) -> (f32, f32) { + let (logical_w, logical_h) = self.backend.logical_size(); + crate::event::pointer::map_offset_to_logical( + offset_x, + offset_y, + self.canvas.client_width().max(1) as f32, + self.canvas.client_height().max(1) as f32, + logical_w, + logical_h, + ) + } } impl crate::repaint_ctx::RepaintContext for CkInner { @@ -493,12 +715,8 @@ impl crate::repaint_ctx::RepaintContext for CkInner { fn canvas_data_url(&self, mime: &str) -> Result { self.canvas.to_data_url_with_type(mime) } - fn register_system_font(&mut self, _family: &str, _bytes: &[u8]) -> bool { - // CanvasKit dynamic OS-font registration is not wired yet — canvas text - // shapes against the bundled Roboto/CJK/emoji faces. The font-query flow - // still runs (the picker lists OS families); only byte registration is a - // no-op, so used families simply fall back to the bundled faces. - false + fn register_system_font(&mut self, family: &str, bytes: &[u8]) -> bool { + self.backend.ck.register_system_font(family, bytes) } fn repaint(&mut self) -> Result<(), JsValue> { // CanvasKit present is infallible (GPU flush, no pixel round-trip). @@ -507,13 +725,41 @@ impl crate::repaint_ctx::RepaintContext for CkInner { } } +/// Resolve an accessibility DOM event's target to its `accesskit::NodeId` +/// and route it into the host (#57). `is_focus` distinguishes `focusin` +/// from `click`. Repaints on a state change so the canvas + the mirror +/// re-sync with the screen-reader-driven focus / activation. +fn dispatch_a11y_dom_event( + inner: &std::rc::Rc>, + target: Option, + is_focus: bool, +) { + use wasm_bindgen::JsCast; + let Some(element) = target.and_then(|t| t.dyn_into::().ok()) else { + return; + }; + let Some(node_id) = crate::a11y_dom::A11yDomMirror::node_id_for_target(&element) else { + return; + }; + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.host.set_clocks( + crate::listener::now_ms_perf(), + crate::listener::now_unix_secs(), + ); + if b.host.apply_a11y_action(node_id.0, is_focus) { + b.repaint(); + } +} + /// Mount the full editor chrome on `canvas_id`, rendered via CanvasKit on the /// GPU, with mouse / wheel / keyboard interactivity. Builds the shared /// `WidgetHost` (skia-free under this feature) and drives it through /// `CanvasKitBackend`, behind the same `RenderBackend` the desktop host uses. #[wasm_bindgen] pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { - use crate::listener::{add_listener, now_ms_perf, Listener}; + use crate::listener::{add_listener, now_ms_perf, now_unix_secs, Listener}; use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::JsCast; @@ -541,12 +787,17 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { let backend = init_backend(&canvas_id, dpr, logical_w, logical_h).await?; let host = crate::widget_host::WidgetHost::new(); + // Hidden ARIA DOM mirror (#57) — created next to the canvas, refreshed + // after every paint so screen readers can read the opaque GPU surface. + let a11y = crate::a11y_dom::A11yDomMirror::create(&canvas); let inner = Rc::new(RefCell::new(CkInner { backend, host, canvas: canvas.clone(), + a11y, })); inner.borrow_mut().repaint(); + crate::web_fonts::drain_font_requests(&inner); // Populate the chat model picker from the daemon's `/api/ai/models` // catalog (best-effort; async, repaints when the response lands). @@ -559,6 +810,42 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { let canvas_target: web_sys::EventTarget = canvas.clone().into(); let win_target: web_sys::EventTarget = window.clone().into(); + // Accessibility DOM mirror (#57): delegated `focus` / `click` on the + // hidden mirror container map a focused/activated mirror node back to a + // host action (focus chat input, blur it on canvas/panel focus, …) then + // repaint so the canvas reflects the screen-reader-driven change. + if let Some(mirror_target) = inner + .borrow() + .a11y + .as_ref() + .map(|m| -> web_sys::EventTarget { m.container().clone().into() }) + { + // `focusin` bubbles (unlike `focus`), so a single delegated listener + // on the container catches focus landing on any descendant node. + { + let inner = inner.clone(); + add_listener::( + &mirror_target, + "focusin", + &mut listeners, + move |evt| { + dispatch_a11y_dom_event(&inner, evt.target(), true); + }, + )?; + } + { + let inner = inner.clone(); + add_listener::( + &mirror_target, + "click", + &mut listeners, + move |evt| { + dispatch_a11y_dom_event(&inner, evt.target(), false); + }, + )?; + } + } + // mousedown → press / right-press { let inner = inner.clone(); @@ -576,11 +863,14 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { if matches!(action, MousePressAction::MiddlePan) { evt.prevent_default(); } - let mut b = inner.borrow_mut(); + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; b.host.set_modifier_shift(evt.shift_key()); - b.host.set_now_ms(now_ms_perf()); + b.host.set_clocks(now_ms_perf(), now_unix_secs()); let (w, h) = b.backend.logical_size(); - let (x, y) = (evt.offset_x() as f32, evt.offset_y() as f32); + let (x, y) = + b.event_offset_to_logical(evt.offset_x() as f32, evt.offset_y() as f32); let consumed = match action { MousePressAction::PrimaryPress => b.host.apply_press(x, y, w, h), MousePressAction::MiddlePan => { @@ -605,6 +895,8 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { crate::dom_io::drain_pending_file_action(&inner); crate::dom_io::drain_pending_attachment_pick(&inner); crate::dom_io::drain_pending_kit_io(&inner); + crate::web_agent_connect::drain_pending_provider_connect(&inner); + crate::web_acp_connect::drain_pending_acp_agent_connect(&inner); crate::web_fonts::drain_font_requests(&inner); }, )?; @@ -629,11 +921,13 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { "mousemove", &mut listeners, move |evt| { - let mut b = inner.borrow_mut(); - b.host.set_now_ms(now_ms_perf()); - if b.host - .apply_cursor_move(evt.offset_x() as f32, evt.offset_y() as f32) - { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.host.set_clocks(now_ms_perf(), now_unix_secs()); + let (x, y) = + b.event_offset_to_logical(evt.offset_x() as f32, evt.offset_y() as f32); + if b.host.apply_cursor_move(x, y) { b.repaint(); } }, @@ -649,8 +943,10 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { if evt.button() == 1 { evt.prevent_default(); } - let mut b = inner.borrow_mut(); - b.host.set_now_ms(now_ms_perf()); + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.host.set_clocks(now_ms_perf(), now_unix_secs()); let (w, h) = b.backend.logical_size(); let was_middle = evt.button() == 1; if b.host.apply_release_with_viewport(w, h) { @@ -668,8 +964,11 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { use crate::event::pointer::{classify_wheel_intent, WheelIntent}; evt.prevent_default(); - let mut b = inner.borrow_mut(); + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; let (w, h) = b.backend.logical_size(); + let (x, y) = b.event_offset_to_logical(evt.offset_x() as f32, evt.offset_y() as f32); let consumed = match classify_wheel_intent( evt.delta_x() as f32, evt.delta_y() as f32, @@ -678,18 +977,8 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { evt.meta_key(), evt.alt_key(), ) { - WheelIntent::Zoom { delta_y } => { - b.host - .apply_wheel(evt.offset_x() as f32, evt.offset_y() as f32, delta_y, w, h) - } - WheelIntent::Pan { dx, dy } => b.host.apply_pan_gesture( - evt.offset_x() as f32, - evt.offset_y() as f32, - dx, - dy, - w, - h, - ), + WheelIntent::Zoom { delta_y } => b.host.apply_wheel(x, y, delta_y, w, h), + WheelIntent::Pan { dx, dy } => b.host.apply_pan_gesture(x, y, dx, dy, w, h), }; if consumed { b.repaint(); @@ -708,8 +997,10 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { if evt.is_composing() { return; } - let mut b = inner.borrow_mut(); - b.host.set_now_ms(now_ms_perf()); + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.host.set_clocks(now_ms_perf(), now_unix_secs()); let key = evt.key(); let starts_space_pan = evt.code() == "Space" && !evt.repeat() @@ -803,7 +1094,9 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { if evt.code() != "Space" { return; } - let mut b = inner.borrow_mut(); + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; b.host.set_space_pan(false); evt.prevent_default(); })?; @@ -813,7 +1106,9 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { let inner = inner.clone(); let window_for_resize = window.clone(); add_listener::(&win_target, "resize", &mut listeners, move |_evt| { - let mut b = inner.borrow_mut(); + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; match b.resize_to_window(&window_for_resize) { Ok(true) => b.repaint(), Ok(false) => {} diff --git a/crates/op-host-web/src/lib.rs b/crates/op-host-web/src/lib.rs index e4fd7558b..937b3b444 100644 --- a/crates/op-host-web/src/lib.rs +++ b/crates/op-host-web/src/lib.rs @@ -13,6 +13,11 @@ //! CanvasKit is the only web renderer now, and `skia-safe` reverted to upstream //! crates.io, which has no wasm32-unknown-unknown target.) +// Hidden ARIA DOM mirror (#57). Pure accesskit + web_sys — no skia, no +// `op-editor-core` — so it compile-checks under BOTH the production +// `canvaskit` build (where the mount wires it) and the wasm32-clean `web` +// stub baseline (compile coverage only). +mod a11y_dom; #[cfg(feature = "canvaskit")] mod canvaskit; pub mod event; diff --git a/crates/op-host-web/src/widget_host/a11y_bridge.rs b/crates/op-host-web/src/widget_host/a11y_bridge.rs index 8aed615ad..8117567fa 100644 --- a/crates/op-host-web/src/widget_host/a11y_bridge.rs +++ b/crates/op-host-web/src/widget_host/a11y_bridge.rs @@ -1,26 +1,232 @@ -//! Host-side bridge for the hidden accessibility DOM layer. +//! Host-side bridge for the hidden accessibility DOM mirror (#57). //! -//! The skia-era `crate::a11y` layer was retired with the skia mount -//! (2026-06-17); these accessors / mutators are kept (tested) so the CanvasKit -//! mount can re-wire an accessibility layer without re-deriving them. Hence the -//! `#[allow(dead_code)]` — currently exercised only by the tests below. +//! Two responsibilities, mirroring `op-host-native/src/widget_host/a11y.rs`: +//! +//! 1. **Enumerate** the editor's always-present regions (top bar, layer +//! panel, canvas, property panel, toolbar, chat, status bar) at the +//! rects the web paint pass (`widget_host/paint.rs`) places them, and +//! hand them to the platform-free assembler +//! (`op_editor_ui::accessibility`) to build an `accesskit::TreeUpdate`. +//! The CanvasKit mount renders that update into the hidden DOM mirror +//! (`crate::a11y_dom`) so screen readers can read the opaque canvas. +//! 2. **Route** incoming DOM accessibility events (focus / click on a +//! mirror node) back into editor state — focusing the chat input, +//! blurring it when focus moves to the canvas / a panel, or activating +//! a tool. These keep the painted frame in lock-step with the screen +//! reader's focus. +//! +//! The actual tree shape / NodeId mapping / focus resolution lives in the +//! assembler; this file is only the host-side enumeration + action +//! handlers (the `EditorState`-aware half that can't live in the +//! platform-free `a11y_dom` module). use super::WidgetHost; +use op_editor_ui::accessibility::{assemble_tree_update, PlacedWidget}; +use op_editor_ui::widgets::{ + AIChatPlaceholder, CanvasViewport, LayerPanel, LayoutCx, PropertyPanel, StatusBar, Toolbar, + Widget, WidgetId, ROOT_WIDGET_ID, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH, TOOLBAR_WIDTH, + TOP_BAR_HEIGHT, +}; +use op_editor_ui::{Point2D, Rect}; + +use super::{STATUS_INSET, TOOLBAR_INSET_X, TOOLBAR_INSET_Y}; + +// Stable widget ids of the always-present regions, mirrored from each +// widget's constructor (`WidgetId::new(..)` in op-editor-ui). Used for +// focus targeting + action routing without constructing the widget twice. +// Kept in sync with the native host's a11y constants. +const AI_CHAT_WIDGET_ID: u64 = 7000; +const PROPERTY_PANEL_WIDGET_ID: u64 = 2000; +const CANVAS_WIDGET_ID: u64 = 4000; +const TOOLBAR_WIDGET_ID: u64 = 3000; -#[allow(dead_code)] impl WidgetHost { - /// Read-only editor state for the a11y mirror's diff/sync pass. - /// Unlike [`WidgetHost::editor_state`] (gated behind the - /// `codegen` / `live-sync` features for its callers), this is - /// unconditional within the skia host build — the a11y layer - /// ships with every real bundle. - pub(crate) fn a11y_editor_state(&self) -> &op_editor_core::EditorState { - &self.editor_state + /// Assemble the accessibility tree for the current editor frame. + /// + /// Enumerates the SAME always-present regions the web paint pass + /// composes (`widget_host/paint.rs`), pairs each with the rect it is + /// painted at, and hands the ordered slice to the platform-free + /// assembler. Order = screen-reader reading order. Transient overlays + /// (pickers, modals, context menus) are intentionally omitted for v1. + /// + /// Takes `&mut self` because the canvas region reads the + /// layout-resolved scene, which `refresh_layout_scene` lazily rebuilds + /// when editor state is dirty — same contract as `paint`. + pub(crate) fn accessibility_tree_update( + &mut self, + viewport_width: f32, + viewport_height: f32, + ) -> accesskit::TreeUpdate { + // Keep the canvas scene in sync with editor state (cheap no-op when + // not dirty) so the CanvasViewport widget matches what paint draws. + self.refresh_layout_scene(); + self.last_viewport_w = viewport_width; + self.last_viewport_h = viewport_height; + + let window_bounds = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(viewport_width, viewport_height), + }; + + let ui = &self.editor_state.editor_ui; + let dpi = 1.0; // Toolbar layout is dpi-independent (fixed metrics). + + // 1. TopBar — full-width top strip. + let top_bar = self.top_bar(); + let top_bar_rect = self.top_bar_rect(viewport_width); + + // 2. LayerPanel — left rail, only when the sidebar is open. + let layer_panel = LayerPanel::from_editor(&self.editor_state); + let layer_panel_rect = self.layer_panel_rect(viewport_height); + + // 3. CanvasViewport — middle band (sidebar / right-rail aware). + let (canvas_left, _canvas_y, canvas_w, canvas_h) = + self.canvas_region(viewport_width, viewport_height); + let canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene); + let canvas_rect = Rect { + origin: Point2D::new(canvas_left, TOP_BAR_HEIGHT), + size: Point2D::new(canvas_w, canvas_h), + }; + + // 4. PropertyPanel — right rail, only with a selection. + let property_panel = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms); + let property_panel_width = ui.property_panel_width; + let property_rect = Rect { + origin: Point2D::new(viewport_width - property_panel_width, TOP_BAR_HEIGHT), + size: Point2D::new( + property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + + // 5. Toolbar — floating vertical column over the canvas. + let toolbar = Toolbar::for_editor(&self.editor_state); + let toolbar_h = toolbar + .layout(&LayoutCx { + available_width: TOOLBAR_WIDTH, + dpi, + }) + .rect + .size + .y; + let toolbar_rect = Rect { + origin: Point2D::new( + canvas_left + TOOLBAR_INSET_X, + TOP_BAR_HEIGHT + TOOLBAR_INSET_Y, + ), + size: Point2D::new(TOOLBAR_WIDTH, toolbar_h), + }; + let toolbar_visible = canvas_w > TOOLBAR_WIDTH + TOOLBAR_INSET_X * 2.0; + + // 6. AIChatPlaceholder — floating chat panel. + let chat = AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms); + let chat_rect = self.ai_chat_rect(viewport_width, viewport_height); + + // 7. StatusBar — floating bottom-right zoom pill (same geometry the + // web paint pass derives inline). + let status = StatusBar::for_editor(&self.editor_state); + let canvas_right = canvas_left + canvas_w; + let status_rect = if canvas_w > STATUS_BAR_WIDTH + STATUS_INSET * 2.0 { + Some(Rect { + origin: Point2D::new( + canvas_right - STATUS_BAR_WIDTH - STATUS_INSET, + TOP_BAR_HEIGHT + canvas_h - STATUS_BAR_HEIGHT - STATUS_INSET, + ), + size: Point2D::new(STATUS_BAR_WIDTH, STATUS_BAR_HEIGHT), + }) + } else { + None + }; + + // Assemble the ordered, present set. Order = reading order. + let mut placed: Vec> = Vec::with_capacity(8); + placed.push(PlacedWidget::new(&top_bar, top_bar_rect)); + if ui.sidebar_open { + placed.push(PlacedWidget::new(&layer_panel, layer_panel_rect)); + } + if canvas_w > 0.0 && canvas_h > 0.0 { + placed.push(PlacedWidget::new(&canvas, canvas_rect)); + } + if let Some(panel) = property_panel.as_ref() { + placed.push(PlacedWidget::new(panel, property_rect)); + } + if toolbar_visible { + placed.push(PlacedWidget::new(&toolbar, toolbar_rect)); + } + if let Some(rect) = chat_rect { + placed.push(PlacedWidget::new(&chat, rect)); + } + if let Some(rect) = status_rect { + placed.push(PlacedWidget::new(&status, rect)); + } + + let focus = self.accessibility_focus_target(canvas_w, canvas_h, property_panel.is_some()); + + assemble_tree_update(window_bounds, &placed, focus) } - /// Activate a tool from the hidden a11y toolbar — mirrors the - /// painted toolbar's `ToolbarHit::Tool` arm in - /// `widget_host/press.rs` (tool write + shape-picker close). + /// Pick a sensible default focus target for the a11y tree. + /// + /// Order: focused chat input → property panel (when an editable + /// selection is up) → canvas (the editor's primary work surface) → + /// root. The chosen id must be a region actually present this frame, + /// which the assembler re-checks before emitting. + fn accessibility_focus_target( + &self, + canvas_w: f32, + canvas_h: f32, + property_panel_present: bool, + ) -> WidgetId { + if self.editor_state.chat.focused { + return WidgetId::new(AI_CHAT_WIDGET_ID); + } + if property_panel_present && self.editor_state.ui.property_focus.is_some() { + return WidgetId::new(PROPERTY_PANEL_WIDGET_ID); + } + if canvas_w > 0.0 && canvas_h > 0.0 { + return WidgetId::new(CANVAS_WIDGET_ID); + } + ROOT_WIDGET_ID + } + + /// Route an accessibility action targeting a known editor region back + /// into host state. Returns `true` when the action changed state (so + /// the mount repaints + re-publishes the tree). Mirrors the native + /// host's `apply_a11y_action`. + /// + /// `target` is the raw `accesskit::NodeId.0` (== `WidgetId.0`), and + /// `is_focus` distinguishes a `focus` event from a `click` activation. + pub(crate) fn apply_a11y_action(&mut self, target: u64, is_focus: bool) -> bool { + match target { + // AIChat panel — focus or click both focus + ready the chat + // input (mirrors `click.rs` `AIChatHit::FocusInput`). + AI_CHAT_WIDGET_ID => { + self.a11y_focus_chat_input(); + true + } + // Canvas / Toolbar / Property panel — moving a11y focus off the + // chat blurs the chat input so caret + send routing follow the + // screen reader's focus. Only meaningful when the chat holds it. + CANVAS_WIDGET_ID | TOOLBAR_WIDGET_ID | PROPERTY_PANEL_WIDGET_ID if is_focus => { + if self.editor_state.chat.focused { + self.editor_state.chat.focused = false; + self.mark_dirty(); + true + } else { + false + } + } + _ => false, + } + } + + /// Activate a tool from the hidden a11y toolbar — mirrors the painted + /// toolbar's `ToolbarHit::Tool` arm (tool write + shape-picker close). + /// Retained for a future per-tool mirror node set; the v1 mirror only + /// surfaces the toolbar as a single region (so this has no caller yet — + /// it is parity surface mirrored from the native host + exercised by the + /// tests below). + #[allow(dead_code)] pub(crate) fn a11y_set_tool(&mut self, tool: op_editor_core::Tool) { self.editor_state.tool = tool; self.editor_state.editor_ui.shape_picker.open = false; @@ -29,11 +235,11 @@ impl WidgetHost { self.mark_dirty(); } - /// Focus the chat input from the hidden a11y button — mirrors - /// `widget_host/click.rs` `AIChatHit::FocusInput` (focus + clear - /// stale selections), plus the caret-blink anchor reset so the - /// painted caret restarts its phase like a real click. Callers - /// should `set_now_ms` first so the anchor is current. + /// Focus the chat input from the hidden a11y mirror — mirrors + /// `widget_host/click.rs` `AIChatHit::FocusInput` (focus + clear stale + /// selections), plus the caret-blink anchor reset so the painted caret + /// restarts its phase like a real click. Callers should `set_now_ms` + /// first so the anchor is current. pub(crate) fn a11y_focus_chat_input(&mut self) { self.editor_state.chat.focus_input_at_end(self.now_ms); self.editor_state.chat.transcript_selection = None; @@ -43,11 +249,80 @@ impl WidgetHost { #[cfg(test)] mod tests { - use super::WidgetHost; + use super::*; + use op_editor_ui::accessibility::node_id; + + fn host() -> WidgetHost { + WidgetHost::new() + } + + #[test] + fn tree_includes_always_present_regions() { + let mut h = host(); + let update = h.accessibility_tree_update(1280.0, 800.0); + let ids: Vec<_> = update.nodes.iter().map(|(id, _)| *id).collect(); + assert!(ids.contains(&node_id(ROOT_WIDGET_ID))); + assert!(ids.contains(&node_id(WidgetId::new(5000))), "top bar"); + assert!( + ids.contains(&node_id(WidgetId::new(CANVAS_WIDGET_ID))), + "canvas" + ); + assert!( + ids.contains(&node_id(WidgetId::new(AI_CHAT_WIDGET_ID))), + "chat" + ); + // Root advertises every emitted child. + let (_, root) = &update.nodes[0]; + for child in root.children() { + assert!( + update.nodes.iter().any(|(id, _)| id == child), + "root child {child:?} missing a node" + ); + } + } + + #[test] + fn focus_defaults_to_canvas() { + let mut h = host(); + let update = h.accessibility_tree_update(1280.0, 800.0); + assert_eq!(update.focus, node_id(WidgetId::new(CANVAS_WIDGET_ID))); + } + + #[test] + fn focused_chat_input_takes_focus() { + let mut h = host(); + h.editor_state.chat.focused = true; + let update = h.accessibility_tree_update(1280.0, 800.0); + assert_eq!(update.focus, node_id(WidgetId::new(AI_CHAT_WIDGET_ID))); + } + + #[test] + fn a11y_action_on_chat_focuses_input() { + let mut host = host(); + host.set_now_ms(1234); + let changed = host.apply_a11y_action(AI_CHAT_WIDGET_ID, true); + assert!(changed); + assert!(host.editor_state.chat.focused); + } + + #[test] + fn a11y_focus_on_canvas_blurs_chat() { + let mut host = host(); + host.editor_state.chat.focused = true; + let changed = host.apply_a11y_action(CANVAS_WIDGET_ID, true); + assert!(changed); + assert!(!host.editor_state.chat.focused); + } + + #[test] + fn a11y_action_on_unknown_region_is_noop() { + let mut host = host(); + assert!(!host.apply_a11y_action(99999, true)); + } #[test] fn a11y_set_tool_switches_tool_and_closes_shape_picker() { - let mut host = WidgetHost::new(); + let mut host = host(); host.editor_state.editor_ui.shape_picker.open = true; host.a11y_set_tool(op_editor_core::Tool::Frame); assert_eq!(host.editor_state.tool, op_editor_core::Tool::Frame); @@ -57,7 +332,7 @@ mod tests { #[test] fn a11y_focus_chat_input_focuses_and_clears_selections() { - let mut host = WidgetHost::new(); + let mut host = host(); host.set_now_ms(1234); host.editor_state.chat.set_input_text("hello"); host.editor_state.chat.select_all_input(0); @@ -68,4 +343,16 @@ mod tests { assert!(chat.transcript_selection.is_none()); assert_eq!(chat.input.next_blink_flip_ms(1234), 1734); } + + #[test] + fn collapsed_sidebar_drops_layer_panel_region() { + let mut h = host(); + h.editor_state.editor_ui.sidebar_open = false; + let update = h.accessibility_tree_update(1280.0, 800.0); + let ids: Vec<_> = update.nodes.iter().map(|(id, _)| *id).collect(); + assert!( + !ids.contains(&node_id(WidgetId::new(1000))), + "layer panel hidden" + ); + } } diff --git a/crates/op-host-web/src/widget_host/keyboard.rs b/crates/op-host-web/src/widget_host/keyboard.rs index 97d850b23..333dded6a 100644 --- a/crates/op-host-web/src/widget_host/keyboard.rs +++ b/crates/op-host-web/src/widget_host/keyboard.rs @@ -606,6 +606,18 @@ impl WidgetHost { self.mark_dirty(); return true; } + // Escape closes an open layer/page right-click context menu + // (layer-context-menu.tsx:101 — keydown Escape → onClose). + if self + .editor_state + .editor_ui + .layer_context_menu + .take() + .is_some() + { + self.mark_dirty(); + return true; + } if self.editor_state.rename_cancel() { self.mark_dirty(); return true; diff --git a/scripts/install-op.sh b/scripts/install-op.sh new file mode 100755 index 000000000..241ece87c --- /dev/null +++ b/scripts/install-op.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Install the OpenPencil `op` CLI from a GitHub Release. +# +# Detects the host OS + architecture, downloads the matching standalone +# `op-cli-