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:
parent
973af2e24b
commit
0104c8bd61
73
.github/workflows/rust-release.yml
vendored
73
.github/workflows/rust-release.yml
vendored
|
|
@ -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
437
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -150,7 +150,13 @@ scoop install openpencil
|
|||
**CLI (`op`):**
|
||||
|
||||
```bash
|
||||
npm install -g @zseven-w/openpencil
|
||||
brew install zseven-w/openpencil/op
|
||||
```
|
||||
|
||||
Or use the install script (macOS / Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ZSeven-W/openpencil/main/scripts/install-op.sh | bash
|
||||
```
|
||||
|
||||
## Cloning(含 Rust 子系统)
|
||||
|
|
@ -275,10 +281,10 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
## CLI — `op`
|
||||
|
||||
Install globally and control the design tool from your terminal:
|
||||
Install via Homebrew and control the design tool from your terminal:
|
||||
|
||||
```bash
|
||||
npm install -g @zseven-w/openpencil
|
||||
brew install zseven-w/openpencil/op
|
||||
```
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
294
crates/op-editor-ui/src/accessibility.rs
Normal file
294
crates/op-editor-ui/src/accessibility.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(_)),
|
||||
|
|
|
|||
|
|
@ -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(_)),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
253
crates/op-host-desktop/src/a11y.rs
Normal file
253
crates/op-host-desktop/src/a11y.rs
Normal 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) {}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
248
crates/op-host-desktop/src/single_instance.rs
Normal file
248
crates/op-host-desktop/src/single_instance.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
306
crates/op-host-native/src/widget_host/a11y.rs
Normal file
306
crates/op-host-native/src/widget_host/a11y.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
350
crates/op-host-web/src/a11y_dom.rs
Normal file
350
crates/op-host-web/src/a11y_dom.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
105
scripts/install-op.sh
Executable 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 "$@"
|
||||
Loading…
Reference in a new issue