feat(editor): a11y + single-instance + layer-panel Rust↔TS parity

Close the non-web-productionization gaps from the 2026-06-18 recheck:

- a11y (#67/#57): assemble each widget's access_node() into an
  accesskit::TreeUpdate (op-editor-ui/accessibility.rs); publish on
  desktop via accesskit_macos/_windows/_unix SubclassingAdapter off the
  raw window handle (op-host-desktop/a11y.rs, NOT accesskit_winit — the
  casement winit fork), and on web via a hidden ARIA DOM mirror
  (op-host-web/a11y_dom.rs); native/web region enumeration + action
  routing in op-host-{native,web}.
- single-instance (#51): fixed-loopback-port guard + second-launch file
  forwarding to the running window (op-host-desktop/single_instance.rs).
- layer panel: drop-into-container inserts at index 0 (#12a); container
  predicate widened to frame/group/rectangle/ref (#12b); Escape closes
  the context menu on both hosts (#14).
- distribution: Homebrew cask fix + op formula + install-op.sh, README
  CLI install, CI codesign/notarize/signtool scaffolding gated on secrets.

Codex-reviewed (1 BLOCKER + 3 CONCERNs resolved). main.rs, app_handler.rs,
canvaskit.rs and Cargo.lock are staged whole and carry some unrelated
in-progress WIP they're interleaved with.
This commit is contained in:
Kayshen-X 2026-06-18 22:18:07 +08:00
parent 973af2e24b
commit 0104c8bd61
39 changed files with 3021 additions and 96 deletions

View file

@ -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

437
Cargo.lock generated
View file

@ -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",
]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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(

View file

@ -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],

View file

@ -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<accesskit::NodeId> =
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);
}
}

View file

@ -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;

View file

@ -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(_)),

View file

@ -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(_)),

View file

@ -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"

View file

@ -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<Mutex<Option<TreeUpdate>>>;
/// Queue of action requests from assistive tech, drained by the runner.
type ActionQueue = Arc<Mutex<VecDeque<ActionRequest>>>;
/// 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<TreeUpdate> {
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<accesskit_macos::SubclassingAdapter>,
#[cfg(target_os = "windows")]
adapter: Option<accesskit_windows::SubclassingAdapter>,
#[cfg(target_os = "linux")]
adapter: Option<accesskit_unix::Adapter>,
#[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<A11yAction> {
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<accesskit_macos::SubclassingAdapter> {
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<accesskit_windows::SubclassingAdapter> {
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<accesskit_unix::Adapter> {
// 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) {}
}

View file

@ -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<DesktopEvent> 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<DesktopEvent> 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<DesktopEvent> 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<DesktopEvent> 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<DesktopEvent> 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<DesktopEvent> 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<DesktopEvent> 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()

View file

@ -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<Window>,
/// 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<a11y::DesktopA11y>,
ctx: Option<SharedSkiaContext>,
backend: Option<NativeBackend>,
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<EventLoopProxy<DesktopEvent>>,
/// 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<iconify_host::IconifyJob>,
/// 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<provider_probe_host::ProviderConnectJob>,
/// In-flight ACP-agent connect probe (Settings → Agents → ACP
/// Connect), drained by `drain_acp_agent_connect`.
acp_agent_connect_job: Option<acp_agent_probe_host::AcpAgentConnectJob>,
/// Document to open once the window is ready — set from argv by
/// the file-association launch path (`openpencil-desktop X.op`).
initial_file: Option<PathBuf>,
@ -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<PathBuf> = 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::<DesktopEvent>::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}");

View file

@ -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:<port>` 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<path>` 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<Mutex<VecDeque<PathBuf>>>;
/// 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<TcpListener>,
}
/// 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<path>\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<DesktopEvent>, 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());
}
}

View file

@ -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 }

View file

@ -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)]

View file

@ -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<PlacedWidget<'_>> = 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"
);
}
}

View file

@ -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;

View file

@ -0,0 +1,350 @@
//! Hidden ARIA DOM mirror for the CanvasKit web shell (#57).
//!
//! The editor renders to an opaque `<canvas>`, 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<NodeId, HtmlElement>,
/// 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<u64>,
}
impl A11yDomMirror {
/// Create the mirror container and append it as a sibling of `canvas`
/// (or, failing that, to `<body>`). Returns `None` only if the DOM is
/// unreachable (non-browser host).
pub(crate) fn create(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
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::<HtmlElement>().ok()?,
None => {
let el = document.create_element("div").ok()?;
el.set_id(CONTAINER_ID);
let el: HtmlElement = el.dyn_into::<HtmlElement>().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::<web_sys::Node>()))
.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<HtmlElement> {
let el = self.document.create_element("div").ok()?;
let el: HtmlElement = el.dyn_into::<HtmlElement>().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<NodeId> {
let raw = target.get_attribute(NODE_ID_ATTR)?;
raw.parse::<u64>().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");
}
}

View file

@ -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<f32> {
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<crate::a11y_dom::A11yDomMirror>,
}
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<bool, JsValue> {
@ -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<String, JsValue> {
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<std::cell::RefCell<CkInner>>,
target: Option<web_sys::EventTarget>,
is_focus: bool,
) {
use wasm_bindgen::JsCast;
let Some(element) = target.and_then(|t| t.dyn_into::<web_sys::Element>().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::<web_sys::FocusEvent, _, _>(
&mirror_target,
"focusin",
&mut listeners,
move |evt| {
dispatch_a11y_dom_event(&inner, evt.target(), true);
},
)?;
}
{
let inner = inner.clone();
add_listener::<MouseEvent, _, _>(
&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::<web_sys::Event, _, _>(&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) => {}

View file

@ -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;

View file

@ -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<PlacedWidget<'_>> = 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"
);
}
}

View file

@ -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;

105
scripts/install-op.sh Executable file
View file

@ -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-<label>` tarball published by .github/workflows/rust-release.yml,
# and installs the `op` binary into a bin directory on PATH.
#
# Usage:
# ./install-op.sh # install the latest release
# OP_VERSION=0.8.0 ./install-op.sh # pin a specific version
# INSTALL_DIR=$HOME/.local/bin ./install-op.sh # custom install dir
#
# Environment overrides:
# OP_VERSION release version WITHOUT the leading "v" (default: latest)
# INSTALL_DIR install target directory (default: /usr/local/bin)
set -euo pipefail
OWNER="ZSeven-W"
REPO="openpencil"
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
# Resolve the asset "label" token rust-release.yml uses in
# op-cli-<label>.tar.gz: "<os>-<arch>" where os is macos/linux and arch is
# x86_64/aarch64 (the cargo target arch, NOT the cask's x64/arm64 token).
detect_label() {
local os arch
case "$(uname -s)" in
Darwin) os="macos" ;;
Linux) os="linux" ;;
*)
echo "error: unsupported OS '$(uname -s)' (only macOS and Linux are packaged)" >&2
exit 1
;;
esac
case "$(uname -m)" in
x86_64 | amd64) arch="x86_64" ;;
arm64 | aarch64) arch="aarch64" ;;
*)
echo "error: unsupported architecture '$(uname -m)'" >&2
exit 1
;;
esac
printf '%s-%s' "$os" "$arch"
}
# Resolve the release version. Honors OP_VERSION; otherwise queries the
# GitHub API for the latest release tag and strips the leading "v".
resolve_version() {
if [ -n "${OP_VERSION:-}" ]; then
printf '%s' "$OP_VERSION"
return
fi
local api tag
api="https://api.github.com/repos/${OWNER}/${REPO}/releases/latest"
# Pull "tag_name": "vX.Y.Z" out of the JSON without a JSON parser.
tag="$(curl -fsSL "$api" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 | sed 's/.*"\(v\{0,1\}[^"]*\)"$/\1/')"
if [ -z "$tag" ]; then
echo "error: could not resolve the latest release tag from GitHub" >&2
echo " set OP_VERSION explicitly, e.g. OP_VERSION=0.8.0 $0" >&2
exit 1
fi
printf '%s' "${tag#v}"
}
main() {
local label version asset url tmp
label="$(detect_label)"
version="$(resolve_version)"
asset="op-cli-${label}.tar.gz"
url="https://github.com/${OWNER}/${REPO}/releases/download/v${version}/${asset}"
echo "==> Installing op ${version} (${label})"
echo " from ${url}"
tmp="$(mktemp -d)"
# Always clean up the scratch dir, even on early exit.
trap 'rm -rf "$tmp"' EXIT
# Download and unpack. The tarball contains a single bare `op` binary.
curl -fsSL --retry 3 -o "$tmp/${asset}" "$url"
tar -xzf "$tmp/${asset}" -C "$tmp"
if [ ! -f "$tmp/op" ]; then
echo "error: ${asset} did not contain an 'op' binary" >&2
exit 1
fi
chmod +x "$tmp/op"
# Install — use sudo automatically only when the target dir is not
# writable by the current user (e.g. the default /usr/local/bin).
echo "==> Installing to ${INSTALL_DIR}/op"
# Create the dir first (best effort) so the writability test below is
# meaningful for a not-yet-existing custom INSTALL_DIR.
mkdir -p "$INSTALL_DIR" 2>/dev/null || true
if [ -w "$INSTALL_DIR" ]; then
install -m 0755 "$tmp/op" "$INSTALL_DIR/op"
else
echo " (need elevated permissions for ${INSTALL_DIR})"
sudo install -m 0755 "$tmp/op" "$INSTALL_DIR/op"
fi
echo "==> Done. Run 'op --version' to verify."
}
main "$@"