diff --git a/.github/actions/build-binary-packages/action.yml b/.github/actions/build-binary-packages/action.yml index e764a26ac7..1483f58e5c 100644 --- a/.github/actions/build-binary-packages/action.yml +++ b/.github/actions/build-binary-packages/action.yml @@ -25,7 +25,7 @@ runs: using: 'composite' steps: - name: Download Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: keyman-srcpkg path: artifacts/keyman-srcpkg @@ -49,7 +49,7 @@ runs: echo '```' >> $GITHUB_STEP_SUMMARY - name: Store binary packages - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: keyman-binarypkgs-${{ inputs.dist }}_${{ inputs.arch }} path: | diff --git a/.github/labeler.yml b/.github/labeler.yml index fc37bf64b7..2549340624 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -5,86 +5,173 @@ # common ones. The others are commented out. There is still some variance between # folder names and labels; consider this documentation of that ;-) -docs: docs/** +docs: +- changed-files: + - any-glob-to-any-file: 'docs/**' # # Add labels based on changed files using actions/labeler # android/: - - android/** - - resources/teamcity/includes/** - - resources/teamcity/android/** -android/app/: android/KMAPro/** -android/engine/: android/KMEA/** -android/samples/: android/Samples/** + - changed-files: + - any-glob-to-any-file: + - android/** + - resources/teamcity/includes/** + - resources/teamcity/android/** +android/app/: + - changed-files: + - any-glob-to-any-file: + - android/KMAPro/** +android/engine/: + - changed-files: + - any-glob-to-any-file: + - android/KMEA/** +android/samples/: + - changed-files: + - any-glob-to-any-file: + - android/Samples/** common/: - - common/** - - resources/teamcity/includes/** - - resources/teamcity/common/** -common/web/: common/web/** + - changed-files: + - any-glob-to-any-file: + - common/** + - resources/teamcity/includes/** + - resources/teamcity/common/** +common/web/: + - changed-files: + - any-glob-to-any-file: + - common/web/** core/: - - core/** - - resources/teamcity/includes/** - - resources/teamcity/core/** + - changed-files: + - any-glob-to-any-file: + - core/** + - resources/teamcity/includes/** + - resources/teamcity/core/** developer/: - - developer/** - - resources/teamcity/includes/** - - resources/teamcity/developer/** + - changed-files: + - any-glob-to-any-file: + - developer/** + - resources/teamcity/includes/** + - resources/teamcity/developer/** developer/compilers/: - - developer/src/kmc/** - - developer/src/kmcmplib/** - - developer/src/kmc-*/** + - changed-files: + - any-glob-to-any-file: + - developer/src/kmc/** + - developer/src/kmcmplib/** + - developer/src/kmc-*/** developer/ide/: - - developer/src/server/** - - developer/src/tike/** + - changed-files: + - any-glob-to-any-file: + - developer/src/server/** + - developer/src/tike/** ios/: - - ios/** - - resources/teamcity/includes/** - - resources/teamcity/ios/** -ios/app/: ios/keyman/** -ios/engine/: ios/engine/** -ios/samples/: ios/samples/** + - changed-files: + - any-glob-to-any-file: + - ios/** + - resources/teamcity/includes/** + - resources/teamcity/ios/** +ios/app/: + - changed-files: + - any-glob-to-any-file: + - ios/keyman/** +ios/engine/: + - changed-files: + - any-glob-to-any-file: + - ios/engine/** +ios/samples/: + - changed-files: + - any-glob-to-any-file: + - ios/samples/** linux/: - - linux/** - - resources/teamcity/includes/** - - resources/teamcity/linux/** -linux/config/: linux/keyman-config/** -linux/engine/: linux/ibus-keyman/** + - changed-files: + - any-glob-to-any-file: + - linux/** + - resources/teamcity/includes/** + - resources/teamcity/linux/** +linux/config/: + - changed-files: + - any-glob-to-any-file: + - linux/keyman-config/** +linux/engine/: + - changed-files: + - any-glob-to-any-file: + - linux/ibus-keyman/** mac/: - - mac/** - - resources/teamcity/includes/** - - resources/teamcity/mac/** + - changed-files: + - any-glob-to-any-file: + - mac/** + - resources/teamcity/includes/** + - resources/teamcity/mac/** # mac/config/: # mac/engine/: mac/** -oem/: oem/** -oem/fv/: oem/firstvoices/** -oem/fv/android/: oem/firstvoices/android/** -oem/fv/ios/: oem/firstvoices/ios/** -oem/fv/windows/: oem/firstvoices/windows/** +oem/: + - changed-files: + - any-glob-to-any-file: + - oem/** +oem/fv/: + - changed-files: + - any-glob-to-any-file: + - oem/firstvoices/** +oem/fv/android/: + - changed-files: + - any-glob-to-any-file: + - oem/firstvoices/android/** +oem/fv/ios/: + - changed-files: + - any-glob-to-any-file: + - oem/firstvoices/ios/** +oem/fv/windows/: + - changed-files: + - any-glob-to-any-file: + - oem/firstvoices/windows/** -resources/: resources/** +resources/: + - changed-files: + - any-glob-to-any-file: + - resources/** web/: - - web/** - - resources/teamcity/includes/** - - resources/teamcity/web/** + - changed-files: + - any-glob-to-any-file: + - web/** + - resources/teamcity/includes/** + - resources/teamcity/web/** # web/bookmarklet/ -web/engine/: web/source/** -web/ui/: web/source/kmwui* -web/samples/: web/samples/** -web/predictive-text/: web/src/engine/predictive-text/** +web/engine/: + - changed-files: + - any-glob-to-any-file: + - web/source/** +web/ui/: + - changed-files: + - any-glob-to-any-file: + - web/source/kmwui* +web/samples/: + - changed-files: + - any-glob-to-any-file: + - web/samples/** +web/predictive-text/: + - changed-files: + - any-glob-to-any-file: + - web/src/engine/predictive-text/** windows/: - - windows/** - - resources/teamcity/includes/** - - resources/teamcity/windows/** -windows/config/: windows/src/desktop/** -windows/engine/: windows/src/engine/** + - changed-files: + - any-glob-to-any-file: + - windows/** + - resources/teamcity/includes/** + - resources/teamcity/windows/** +windows/config/: + - changed-files: + - any-glob-to-any-file: + - windows/src/desktop/** +windows/engine/: + - changed-files: + - any-glob-to-any-file: + - windows/src/engine/** diff --git a/.github/workflows/api-verification.yml b/.github/workflows/api-verification.yml index af6bc682dc..56726817dc 100644 --- a/.github/workflows/api-verification.yml +++ b/.github/workflows/api-verification.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Restore artifacts - uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | artifacts @@ -55,7 +55,7 @@ jobs: - name: Checkout if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ steps.environment_step.outputs.GIT_SHA }}' fetch-depth: 0 @@ -84,7 +84,7 @@ jobs: - name: Archive .symbols file if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' && always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: libkeymancore.symbols path: ${{ github.workspace }}/keyman/linux/debian/tmp/DEBIAN/symbols diff --git a/.github/workflows/build-test-publish-docker.yml b/.github/workflows/build-test-publish-docker.yml index 3c85d4ff91..edb9ebb98f 100644 --- a/.github/workflows/build-test-publish-docker.yml +++ b/.github/workflows/build-test-publish-docker.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout repository id: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Build Docker images id: build run: | @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout repository id: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Test Docker images id: test diff --git a/.github/workflows/core-arm64-windows-test.yml b/.github/workflows/core-arm64-windows-test.yml index 237e60d4ef..0eb4e8f8f6 100644 --- a/.github/workflows/core-arm64-windows-test.yml +++ b/.github/workflows/core-arm64-windows-test.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' @@ -64,7 +64,7 @@ jobs: npm -v node -v - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' @@ -119,7 +119,7 @@ jobs: - name: Install meson shell: bash run: | - pip3 install --user meson + pip3 install --user meson==1.10.1 - name: Run test shell: bash diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 7503d51d21..d0c14de948 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: crowdin action uses: crowdin/github-action@v2.7.0 diff --git a/.github/workflows/deb-packaging.yml b/.github/workflows/deb-packaging.yml index 36bfd0d0ce..6e7fe54cb3 100644 --- a/.github/workflows/deb-packaging.yml +++ b/.github/workflows/deb-packaging.yml @@ -43,7 +43,7 @@ jobs: PRERELEASE_TAG: ${{ steps.prerelease_tag.outputs.PRERELEASE_TAG }} steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' @@ -121,7 +121,7 @@ jobs: echo "- $(find . -name keyman_\*.dsc)" >> $GITHUB_STEP_SUMMARY - name: Store source package - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: keyman-srcpkg path: | @@ -140,7 +140,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' sparse-checkout: '.github/actions/' @@ -166,7 +166,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' sparse-checkout: '.github/actions/' @@ -204,13 +204,13 @@ jobs: sudo rm -rf /usr/share/dotnet - name: Download Source Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: keyman-srcpkg path: artifacts - name: Download Binary Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts pattern: keyman-binarypkgs-* @@ -281,7 +281,7 @@ jobs: echo "::endgroup::" - name: Download Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: keyman-signedpkgs @@ -343,7 +343,7 @@ jobs: steps: - name: Download Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts pattern: keyman-* @@ -362,7 +362,7 @@ jobs: echo "SKIP_API_CHECK=${{ github.event.client_payload.skipApiCheck }}" >> artifacts/env - name: Cache artifacts - uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | artifacts diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 42d9f79363..ee7fe7d03d 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Update labels based on changed files - uses: actions/labeler@v4 + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" - name: Update labels based on PR title diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index fc94cc688e..551045c056 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -50,7 +50,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' diff --git a/.github/workflows/pr-build-status.yml b/.github/workflows/pr-build-status.yml index 978a2b0995..bd5fe2f46a 100644 --- a/.github/workflows/pr-build-status.yml +++ b/.github/workflows/pr-build-status.yml @@ -1,40 +1,40 @@ # GENERATED FILE - DO NOT EDIT! -# -# Keyman is copyright (C) SIL Global. MIT License. -# -# Do not modify the script in .github/workflows/pr-build-status.yml directly; -# instead work on the sources in resources/build/pr-build-status and use the -# build.sh script to rebuild the .github/workflows/pr-build-status.yml file from -# them. -# -# build.sh will append the relevant portions of -# resources/build/pr-build-status/pr-build-status.mjs to the content in -# resources/build/pr-build-status/pr-build-status.prefix.yml to form the .github -# workflow file. -# -name: Keyman Build Summary -on: - status: - push: - branches-ignore: - - master - - beta - - stable-* - workflow_dispatch: - inputs: - commit: - description: 'Commit sha' - required: true - type: string -jobs: - run_pr_build_status: - name: Summarize build status checks - runs-on: ubuntu-latest - steps: - - name: Check PR build status - id: run_pr_build_status_script - uses: actions/github-script@v7 - with: +# +# Keyman is copyright (C) SIL Global. MIT License. +# +# Do not modify the script in .github/workflows/pr-build-status.yml directly; +# instead work on the sources in resources/build/pr-build-status and use the +# build.sh script to rebuild the .github/workflows/pr-build-status.yml file from +# them. +# +# build.sh will append the relevant portions of +# resources/build/pr-build-status/pr-build-status.mjs to the content in +# resources/build/pr-build-status/pr-build-status.prefix.yml to form the .github +# workflow file. +# +name: Keyman Build Summary +on: + status: + push: + branches-ignore: + - master + - beta + - stable-* + workflow_dispatch: + inputs: + commit: + description: 'Commit sha' + required: true + type: string +jobs: + run_pr_build_status: + name: Summarize build status checks + runs-on: ubuntu-latest + steps: + - name: Check PR build status + id: run_pr_build_status_script + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: script: | // This code is copied out of resources/build/pr-build-status/pr-build-status.mjs // where it is tested. It is copied inline here in order to avoid requiring the diff --git a/HISTORY.md b/HISTORY.md index 8a045c92e0..35a5626afe 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,39 @@ # Keyman Version History +## 19.0.221 alpha 2026-03-27 + +* fix(common): add missing `default` property to keyman-touch-layout.clean.spec.json (#15787) +* chore(deps): bump picomatch from 4.0.3 to 4.0.4 in /developer/src/server/src/win32/trayicon/addon-src (#15786) +* chore(deps): bump picomatch from 2.3.1 to 2.3.2 (#15784) +* chore(deps-dev): bump flatted from 3.2.5 to 3.4.2 (#15773) +* chore(deps): bump brace-expansion from 5.0.4 to 5.0.5 in /developer/src/server/src/win32/trayicon/addon-src (#15791) + +## 19.0.220 alpha 2026-03-26 + +* fix(mac): improved adherence to backspace rules for compliant apps (#15561) + +## 19.0.219 alpha 2026-03-25 + +* maint(common): Update GitHub actions for Node 24 (#15762) +* fix(core): fix keydown/up handling for LDML keyboards (#15609) + +## 19.0.218 alpha 2026-03-24 + +* fix(developer): define globalThis for compiled custom lexical models (#15777) + +## 19.0.217 alpha 2026-03-17 + +* maint(mac): search for brew-installed rsync (#15765) + +## 19.0.216 alpha 2026-03-16 + +* docs: tweak walkthrough content (#15712) +* chore(deps): bump tar from 7.5.10 to 7.5.11 in /developer/src/server/src/win32/trayicon/addon-src (#15723) +* docs(linux): add documentation how input methods work in GTK (#15749) +* docs(core): improve keyhandling doc (#15736) +* maint(resources): try meson 1.10.1 (#15754) +* maint(ios): clean carthage before builds (#15757) + ## 19.0.215 alpha 2026-03-13 * chore(linux): fix dependency of Debian test suite (#15735) @@ -1288,6 +1322,15 @@ * refactor(windows): rename `TKeymanMutex.MutexOwned` to `TakeOwnership` and add `ReleaseOwnership` (#13168) * chore: increment to alpha 19.0 (#13187) +## 18.0.249 stable 2026-03-27 + +* chore(linux): Update debian changelog (#15717) +* chore(linux): fix dependency of Debian test suite (#15734) +* chore(linux): Update debian changelog (#15731) +* maint(ios): Upgrade Sentry to 8.58.0 to support XCode 26 (#15755) +* fix(developer): define globalThis for compiled custom lexical models (#15778) +* fix(common): add missing `default` property to keyman-touch-layout.clean.spec.json (#15792) + ## 18.0.248 stable 2026-03-12 * fix(ios): Revert Sentry to 8.38.0 to fix FirstVoices crash on startup with 18.0.247 (#15726) diff --git a/VERSION.md b/VERSION.md index 78ca871179..64f8c1199b 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.216 \ No newline at end of file +19.0.222 \ No newline at end of file diff --git a/common/schemas/keyman-touch-layout/README.md b/common/schemas/keyman-touch-layout/README.md index 1276bed54c..e6984ca142 100644 --- a/common/schemas/keyman-touch-layout/README.md +++ b/common/schemas/keyman-touch-layout/README.md @@ -457,6 +457,10 @@ string") into the appropriate spec format. # .keyman-touch-layout version history +## 2026-03-26 2.1.2 stable +* Add missing 'default' property for longpress (sk) keys to clean spec. No other + changes. + ## 2024-02-23 2.1.1 stable * Loosen `layer.id` requirements to any non-whitespace characters, recommend only alphanumeric, -, _. clean spec enforces this recommendation. diff --git a/common/schemas/keyman-touch-layout/keyman-touch-layout.clean.spec.json b/common/schemas/keyman-touch-layout/keyman-touch-layout.clean.spec.json index 8bdbf7f66a..032d6ee49f 100644 --- a/common/schemas/keyman-touch-layout/keyman-touch-layout.clean.spec.json +++ b/common/schemas/keyman-touch-layout/keyman-touch-layout.clean.spec.json @@ -85,9 +85,9 @@ "text": { "type": "string" }, "layer": { "$ref": "#/definitions/layer-id" }, "nextlayer": { "$ref": "#/definitions/layer-id" }, - "font": { "$ref": "#/definitions/font-spec" }, "fontsize": { "$ref": "#/definitions/fontsize-spec" }, - "sp": { "$ref": "#/definitions/key-sp" }, + "font": { "$ref": "#/definitions/font-spec" }, + "sp": { "$ref" : "#/definitions/key-sp" }, "pad": { "$ref" : "#/definitions/key-pad" }, "width": { "$ref" : "#/definitions/key-width" }, "sk": { "$ref": "#/definitions/subkeys" }, @@ -140,11 +140,12 @@ "text": { "type": "string" }, "layer": { "$ref": "#/definitions/layer-id" }, "nextlayer": { "$ref": "#/definitions/layer-id" }, - "font": { "$ref": "#/definitions/font-spec" }, - "fontsize": { "$ref": "#/definitions/fontsize-spec" }, - "sp": { "$ref": "#/definitions/key-sp" }, + "sp": { "$ref" : "#/definitions/key-sp" }, "pad": { "$ref" : "#/definitions/key-pad" }, - "width": { "$ref" : "#/definitions/key-width" } + "width": { "$ref" : "#/definitions/key-width" }, + "fontsize": { "$ref": "#/definitions/fontsize-spec" }, + "font": { "$ref": "#/definitions/font-spec" }, + "default": { "type": "boolean" } }, "required": ["id"], "additionalProperties": false diff --git a/core/docs/api/keyhandling.md b/core/docs/api/keyhandling.md index 6dcc3177b0..76576b56d8 100644 --- a/core/docs/api/keyhandling.md +++ b/core/docs/api/keyhandling.md @@ -1,42 +1,39 @@ # Key Handling -For each key press the processor will called twice, for the key down event -as well as for the key up event. Depending on the type of key pressed the -processor might handle the key itself or pass it on to the application. -The value of `emit_keystroke` in `km_core_actions` struct tells if the -processor handled the key (`emit_keystroke=0`) or not (`emit_keystroke=1`). -It is important that the same value gets set for both key down and key up -events, otherwise the application might miss some events which then looks -to the user like keys are stuck. +While Keyman Core does not generally emit actions from key up events, +the `emit_keystroke` value for a key up event will match the corresponding +key down event, in order to avoid 'stuck key' scenarios'. Usually the processor won't handle any frame keys and let the application deal with it. This allows shortcut keys like Ctrl+C -to work. The only exception is the Backspace key which is -handled internally if enough context is available. Regular keys will be -handled by the processor. +to work. One exception is the Backspace key which is +handled internally if enough context is available. Keyboards can define +rules for other frame keys, e.g. Ctrl+Key which are then +also handled internally. Regular keys will be handled by the processor. Note that there is a difference between CLDR/LDML and KMN keyboards if there are no rules/transforms defined for a key: KMN keyboards will output the cap value of the key (e.g. pressing a will output `a` if no rule is defined), whereas CLDR/LDML keyboards will suppress -any output for that key. +any output for that key. However, `km_core_actions.emit_keystroke` will +be `FALSE` for both KMN and CLR/LDML keyboards. The following table lists the state of `km_core_actions.emit_keystroke` on return of `km_core_process_event` when the following type of key is pressed: -|Type | KeyDown | KeyUp | -|-----------------------------|---------------|----------| -|Key with rule | FALSE | FALSE | -|Key w/o rule | FALSE | FALSE | -|Framekeys: | | | -|Enter | TRUE | TRUE | -|Backspace¹ | FALSE | FALSE | -|Backspace² | TRUE | TRUE | -|Ctrl+Key | TRUE | TRUE | -|Modifier key Shift| TRUE | TRUE | -|Modifier key Ctrl | TRUE | TRUE | -|Modifier key LAlt | TRUE | TRUE | +|Type | KeyDown/Up | +|-----------------------------|------------| +|Core key with rule | FALSE | +|Core key w/o rule | FALSE | +|Framekeys: | | +|Enter | TRUE | +|Backspace¹ | FALSE | +|Backspace² | TRUE | +|Ctrl+Key | TRUE | +|Modifier key Shift| TRUE | +|Modifier key Ctrl | TRUE | +|Modifier key LAlt | TRUE | 1: context available
2: without or with empty context diff --git a/core/src/km_core_processevent_api.cpp b/core/src/km_core_processevent_api.cpp index a2bca95ea8..ade2665353 100644 --- a/core/src/km_core_processevent_api.cpp +++ b/core/src/km_core_processevent_api.cpp @@ -50,6 +50,9 @@ km_core_process_event(km_core_state *state, if(state == nullptr) { return KM_CORE_STATUS_INVALID_ARGUMENT; } + if (vk == KM_CORE_VKEY_BKSP && is_key_down) { + state->set_backspace_handled_internally(false); + } km_core_status status = state->processor().process_event(state, vk, modifier_state, is_key_down, event_flags); if (state_should_invalidate_context(state, vk, modifier_state, is_key_down, event_flags)) { @@ -78,6 +81,10 @@ km_core_process_event(km_core_state *state, state->apply_actions_and_merge_app_context(); + if (vk == KM_CORE_VKEY_BKSP) { + state->set_backspace_handled_internally(!state->action_struct().emit_keystroke); + } + return status; } diff --git a/core/src/ldml/ldml_processor.cpp b/core/src/ldml/ldml_processor.cpp index 9ef36b62fc..e79a1f90be 100644 --- a/core/src/ldml/ldml_processor.cpp +++ b/core/src/ldml/ldml_processor.cpp @@ -179,21 +179,13 @@ ldml_processor::process_event( ldml_state.clear(); try { - if (!is_key_down) { - process_key_up(ldml_state); + if (vk == KM_CORE_VKEY_BKSP) { + process_backspace(ldml_state); + } else if (is_key_down) { + process_key_down(ldml_state); } else { - switch (vk) { - // Currently, only one VK gets spoecial treatment. - // Special handling for backspace VK - case KM_CORE_VKEY_BKSP: - process_backspace(ldml_state); - break; - default: - // all other VKs - process_key_down(ldml_state); - } // end of switch - } // end of normal processing - + process_key_up(ldml_state); + } // all key-up and key-down events end up here. // commit the ldml state into the core state ldml_state.commit(); @@ -210,11 +202,31 @@ void ldml_processor::process_key_up(ldml_event_state &ldml_state) const { // TODO-LDML: Implement caps lock handling - ldml_state.clear(); + + // Look up the key + bool found = false; + const std::u16string key_str = keys.lookup(ldml_state.get_vk(), ldml_state.get_modifier_state(), found); + + if (!found) { + ldml_state.emit_passthrough_keystroke(); + } } void ldml_processor::process_backspace(ldml_event_state &ldml_state) const { + if (ldml_state.get_modifier_state() & K_MODIFIERFLAG) { + // we never process modifier+bksp + ldml_state.emit_passthrough_keystroke(); + return; + } + + if (!ldml_state.is_key_down()) { + if (!ldml_state.get_state()->backspace_handled_internally()) { + ldml_state.emit_passthrough_keystroke(); + } + return; + } + if (!!bksp_transforms) { // process with an empty string via the bksp transforms auto matchedContext = process_output(ldml_state, std::u32string(), bksp_transforms.get()); diff --git a/core/src/ldml/ldml_processor.hpp b/core/src/ldml/ldml_processor.hpp index b673a9275c..a10294cc32 100644 --- a/core/src/ldml/ldml_processor.hpp +++ b/core/src/ldml/ldml_processor.hpp @@ -176,6 +176,9 @@ public: * @return the number of context items consumed */ size_t context_to_string(std::u32string &str, bool include_markers = true); + km_core_state* get_state() const { return state; } + + uint8_t is_key_down() const { return _is_key_down; } private: km_core_virtual_key _vk; diff --git a/core/src/state.cpp b/core/src/state.cpp index c6142b3456..47f29d0a67 100644 --- a/core/src/state.cpp +++ b/core/src/state.cpp @@ -52,6 +52,7 @@ state::state(km::core::abstract_processor & ap, km_core_option_item const *env) env->key, env->value); } + _backspace_handled_internally = false; _imx_callback = nullptr; _imx_object = nullptr; memset(const_cast(&_action_struct), 0, sizeof(km_core_actions)); @@ -117,4 +118,4 @@ void state::apply_actions_and_merge_app_context() { } this->_action_struct.deleted_context = km::core::get_deleted_context(app_context_for_deletion, this->_action_struct.code_points_to_delete); -} \ No newline at end of file +} diff --git a/core/src/state.hpp b/core/src/state.hpp index 49eea8ffd2..0cf58fb614 100644 --- a/core/src/state.hpp +++ b/core/src/state.hpp @@ -130,6 +130,7 @@ protected: core::debug_items _debug_items; km_core_keyboard_imx_platform _imx_callback; void *_imx_object; + bool _backspace_handled_internally; public: state(core::abstract_processor & kb, km_core_option_item const *env); @@ -174,6 +175,22 @@ public: km_core_actions const &actions ); void apply_actions_and_merge_app_context(); + + /** + * This is used to track whether the backspace key was handled internally + * during the keydown event. This is needed so that we can return the same + * value from the keyup event as we did for the keydown event. + * + * Backspace is the only key that we sometimes handle internally (if we + * have enough context) and sometimes not. By the time we get the keyup + * event the context already got updated and so we have no way of knowing + * whether or not the keydown handled it internally. Therefore this + * flag exists. + * + * Only used when processing KM_CORE_VKEY_BKSP with LDML keyboards. + */ + void set_backspace_handled_internally(bool handled) { _backspace_handled_internally = handled; } + bool backspace_handled_internally() const { return _backspace_handled_internally; } }; } // namespace core } // namespace km diff --git a/core/tests/unit/km_core_process_event.tests.cpp b/core/tests/unit/km_core_process_event.tests.cpp new file mode 100644 index 0000000000..67b84760b3 --- /dev/null +++ b/core/tests/unit/km_core_process_event.tests.cpp @@ -0,0 +1,355 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ +#include +#include "path.hpp" +#include "state.hpp" +#include "kmx/kmx_processevent.h" + +#include "emscripten_filesystem.h" +#include "kmnkbd/action_items.hpp" +#include "load_kmx_file.hpp" + +using namespace km::core::kmx; +km::core::path test_dir; + +km_core_option_item test_env_opts[] = {KM_CORE_OPTIONS_END}; + +#define KEY_DOWN TRUE +#define KEY_UP FALSE + +struct TestData { + const char* test_name; + + const char* keyboard_name; + km_core_virtual_key vkey; + uint16_t modifier_state; + + // Initial context + km_core_cu const* context; + + // Whether keydown handled the event. Only relevant for keyup tests. + bool keydown_handled; + + // Expected actions for keydown and keyup + std::initializer_list keydown_actions; + std::initializer_list keyup_actions; +}; + +std::string GenerateTestName(const testing::TestParamInfo& info) { + return info.param.test_name; +} + +class ProcessEventTests : public testing::TestWithParam { +protected: + km_core_keyboard* keyboard = nullptr; + km_core_state* state = nullptr; + KMX_ProcessEvent process_event; + + void Initialize(TestData const& data) { + km::core::path kmxfile = km::core::path(test_dir / data.keyboard_name); + auto blob = km::tests::load_kmx_file(kmxfile.native().c_str()); + + EXPECT_EQ(km_core_keyboard_load_from_blob(kmxfile.stem().c_str(), blob.data(), blob.size(), &this->keyboard), KM_CORE_STATUS_OK); + EXPECT_EQ(km_core_state_create(this->keyboard, test_env_opts, &this->state), KM_CORE_STATUS_OK); + EXPECT_TRUE(this->process_event.Load(blob.data(), blob.size())); + if (data.context) { + EXPECT_EQ(km_core_state_context_set_if_needed(this->state, data.context), KM_CORE_CONTEXT_STATUS_UPDATED); + } + ((km::core::state*)this->state)->set_backspace_handled_internally(data.keydown_handled); + } + + void TearDown() override { + if (this->state) { + km_core_state_dispose(this->state); + this->state = nullptr; + } + if (this->keyboard) { + km_core_keyboard_dispose(this->keyboard); + this->keyboard = nullptr; + } + } +}; + +void print_all_action_items(km_core_state const* state) { + size_t n = 0; + auto act = km_core_state_action_items(state, &n); + std::cout << "Action items:" << std::endl; + for (size_t i = 0; i < n; i++) { + print_action_item("", *act++); + } + std::cout << "---------------" << std::endl; +} + +TEST_P(ProcessEventTests, ReturnsExpectedActionsForKeyDown) { + auto data = GetParam(); + Initialize(data); + EXPECT_EQ(km_core_process_event(this->state, data.vkey, data.modifier_state, + KEY_DOWN, KM_CORE_EVENT_FLAG_DEFAULT), KM_CORE_STATUS_OK); + // print_all_action_items(this->state); + EXPECT_TRUE(action_items(this->state, data.keydown_actions)); +} + +TEST_P(ProcessEventTests, ReturnsExpectedActionsForKeyUp) { + auto data = GetParam(); + Initialize(data); + EXPECT_EQ(km_core_process_event(this->state, data.vkey, data.modifier_state, KEY_UP, KM_CORE_EVENT_FLAG_DEFAULT), KM_CORE_STATUS_OK); + // print_all_action_items(this->state); + EXPECT_TRUE(action_items(this->state, data.keyup_actions)); +} + +union backspace_union { + km_core_backspace_item backspace; + uint32_t value; +}; +// const backspace_union backspace_with_context = {{KM_CORE_BT_CHAR, 'x'}}; +const backspace_union backspace_no_context = {{KM_CORE_BT_CHAR, 0}}; + +const TestData values[] = { + //-------------------------------------------------------------------- + // KMN + // Key with rule + {"KMN_VKey_A", "kmx/k_005___nul_with_initial_context.kmx", KM_CORE_VKEY_A, 0, u"x", true, + { // KeyDown + {KM_CORE_IT_CHAR, { 0, }, {'d'}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + {"KMN_Ctrl_VKey_A", "kmx/k_005___nul_with_initial_context.kmx", KM_CORE_VKEY_A, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Key without rule + {"KMN_VKey_X", "kmx/k_005___nul_with_initial_context.kmx", KM_CORE_VKEY_X, 0, u"x", true, + { // KeyDown + {KM_CORE_IT_CHAR, { 0, }, {'x'}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + {"KMN_Ctrl_VKey_X", "kmx/k_005___nul_with_initial_context.kmx", KM_CORE_VKEY_X, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Frame key without rule + {"KMN_VKey_Enter", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_ENTER, 0, u"x", false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // // Backspace (with available context) + // // TODO: fix the implementation to make this test pass. Currently failing in KeyUp + // {"KMN_VKey_Backspace_Ctxt", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_BKSP, 0, u"x", true, + // { // KeyDown + // // Once we use C++ 20 we can use: + // //{KM_CORE_IT_BACK, { 0, }, {.backspace = {KM_CORE_BT_CHAR, 'x'}}}, + // {KM_CORE_IT_BACK, { 0, }, {backspace_with_context.value}}, + // {KM_CORE_IT_END} + // }, + // { // KeyUp + // {KM_CORE_IT_END} + // } + // }, + // Backspace (without context) + {"KMN_VKey_Backspace_NoCtxt", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_BKSP, 0, NULL, false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Ctrl+Backspace (with context) + {"KMN_Ctrl_VKey_Backspace_Ctxt", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_BKSP, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Ctrl+Backspace (no context) + {"KMN_Ctrl_VKey_Backspace_NoCtxt", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_BKSP, KM_CORE_MODIFIER_LCTRL, NULL, false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Modifier frame key + {"KMN_VKey_Shift", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_SHIFT, 0, u"x", false, + { // KeyDown + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_END} + } + }, + + //-------------------------------------------------------------------- + // LDML + // Key with rule + {"LDML_VKey_A", "ldml/keyboards/k_020_fr.kmx", KM_CORE_VKEY_A, 0, u"x", true, + { // KeyDown + {KM_CORE_IT_CHAR, { 0, }, {'q'}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_END} + } + }, + {"LDML_Ctrl_VKey_A", "ldml/keyboards/k_020_fr.kmx", KM_CORE_VKEY_A, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Key without rule + {"LDML_VKey_X", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_X, 0, u"x", true, + { // KeyDown + {KM_CORE_IT_END} // LDML: no output without rule + }, + { // KeyUp + {KM_CORE_IT_END} + } + }, + {"LDML_Ctrl_VKey_X", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_X, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} // LDML: no output without rule + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Frame key without rule + {"LDML_VKey_Enter", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_ENTER, 0, u"x", false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Backspace (with available context) + {"LDML_VKey_Backspace_Ctxt", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_BKSP, 0, u"x", true, + { // KeyDown + // Once we use C++ 20 we can use: + //{KM_CORE_IT_BACK, { 0, }, {.backspace = {KM_CORE_BT_CHAR, 0}}}, + {KM_CORE_IT_BACK, { 0, }, {backspace_no_context.value}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_END} + } + }, + // Backspace (without context) + {"LDML_VKey_Backspace_NoCtxt", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_BKSP, 0, NULL, false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Ctrl+Backspace (with context) + {"LDML_Ctrl_VKey_Backspace_Ctxt", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_BKSP, KM_CORE_MODIFIER_LCTRL, u"x", false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Ctrl+Backspace (no context) + {"LDML_Ctrl_VKey_Backspace_NoCtxt", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_BKSP, KM_CORE_MODIFIER_LCTRL, NULL, false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Modifier frame key + {"LDML_VKey_Shift", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_SHIFT, 0, u"x", false, + { // KeyDown + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, +}; + +INSTANTIATE_TEST_SUITE_P(KMXProcessEvent, ProcessEventTests, testing::ValuesIn(values), GenerateTestName); + +// provide our own `main` so that we can get the path of the exe so that +// we have a well-defined location to find our test keyboards +int main(int argc, char** argv) { +#ifdef __EMSCRIPTEN__ + test_dir = get_wasm_file_path(km::core::path(argv[0]).parent()); +#else + test_dir = km::core::path(argv[0]).parent(); +#endif + std::cout << "test_dir=" << test_dir.c_str() << std::endl; + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/core/tests/unit/meson.build b/core/tests/unit/meson.build index 0637ace96e..5456650372 100644 --- a/core/tests/unit/meson.build +++ b/core/tests/unit/meson.build @@ -48,6 +48,18 @@ kmcorekeyboardapitests = executable('km_core_keyboard_api.tests', test('km-core-keyboard-api-tests', kmcorekeyboardapitests) +# tests for km_core_process_event +km_core_process_event_tests_exe = executable( + 'km_core_process_event_tests', + ['km_core_process_event.tests.cpp', common_test_files], + include_directories: [inc, libsrc], + cpp_args: defns + warns, + link_args: [ links, extra_link_args ], + dependencies: [icu_uc, icu_i18n, gtest_dep, gmock_dep], + objects: lib.extract_all_objects(recursive: false), +) +test('km_core_process_event', km_core_process_event_tests_exe) + subdir('json') subdir('utftest') subdir('kmnkbd') diff --git a/developer/src/kmc-model/src/lexical-model-compiler.ts b/developer/src/kmc-model/src/lexical-model-compiler.ts index 54b50ba503..9e0a03611a 100644 --- a/developer/src/kmc-model/src/lexical-model-compiler.ts +++ b/developer/src/kmc-model/src/lexical-model-compiler.ts @@ -182,6 +182,7 @@ export class LexicalModelCompiler implements KeymanCompiler { const sources: string[] = modelSource.sources.map(function(source) { return new TextDecoder().decode(callbacks.loadFile(callbacks.path.join(sourcePath, source))); }); + func += `globalThis.exports = globalThis.exports ?? {};\n`; func += this.transpileSources(sources).join('\n'); func += `LMLayerWorker.loadModel(new ${modelSource.rootClass}());\n`; break; diff --git a/developer/src/kmc-model/test/compile-model.tests.ts b/developer/src/kmc-model/test/compile-model.tests.ts index 5c6a8ab104..ac7baa373e 100644 --- a/developer/src/kmc-model/test/compile-model.tests.ts +++ b/developer/src/kmc-model/test/compile-model.tests.ts @@ -19,6 +19,7 @@ describe('LexicalModelCompiler', function () { 'example.qaa.wordbreaker', 'example.qaa.joinwordbreaker', 'example.qaa.scriptusesspaces', + 'example.qaa.custom', ]; for (const modelID of MODELS) { @@ -37,7 +38,11 @@ describe('LexicalModelCompiler', function () { assert.isFalse(compilation.hasSyntaxError, 'model code had syntax error'); assert.isNull(compilation.error, `compilation error: ${compilation.error}`); - assert.equal(compilation.modelConstructorName, 'TrieModel'); + if(modelID == 'example.qaa.custom') { + assert.isNull(compilation.modelConstructorName); + } else { + assert.equal(compilation.modelConstructorName, 'TrieModel'); + } }); } }); diff --git a/developer/src/kmc-model/test/fixtures/example.qaa.custom/ExampleCustomModel.ts b/developer/src/kmc-model/test/fixtures/example.qaa.custom/ExampleCustomModel.ts new file mode 100644 index 0000000000..6cd95e8a35 --- /dev/null +++ b/developer/src/kmc-model/test/fixtures/example.qaa.custom/ExampleCustomModel.ts @@ -0,0 +1,25 @@ +import { LexicalModelTypes } from '@keymanapp/common-types'; + +export class ExampleCustomModel implements LexicalModelTypes.LexicalModel { + configure(capabilities: LexicalModelTypes.Capabilities): LexicalModelTypes.Configuration { + return { + leftContextCodePoints: 16, + rightContextCodePoints: 0, + wordbreaksAfterSuggestions: false, + } + } + + languageUsesCasing: boolean = true; + + predict(transform: LexicalModelTypes.Transform, context: LexicalModelTypes.Context): LexicalModelTypes.Distribution { + if(transform.deleteLeft == 0 && context.left.endsWith('te') && transform.insert == 'h') { + return [ + { p: 0.3, sample: { displayAs: 'the', transform: { deleteLeft: 2, insert: 'the' }, tag: 'correction' } }, + { p: 0.2, sample: { displayAs: 'them', transform: { deleteLeft: 2, insert: 'them' }, tag: 'correction' } }, + { p: 0.1, sample: { displayAs: 'tee hee', transform: { deleteLeft: 2, insert: 'tee hee' }, tag: 'correction' } }, + ]; + } else { + return []; + } + } +} diff --git a/developer/src/kmc-model/test/fixtures/example.qaa.custom/example.qaa.custom.model.ts b/developer/src/kmc-model/test/fixtures/example.qaa.custom/example.qaa.custom.model.ts new file mode 100644 index 0000000000..8f1d188f9d --- /dev/null +++ b/developer/src/kmc-model/test/fixtures/example.qaa.custom/example.qaa.custom.model.ts @@ -0,0 +1,6 @@ +const source: LexicalModelSource = { + format: 'custom-1.0', + rootClass: 'ExampleCustomModel', + sources: ['ExampleCustomModel.ts'], +}; +export default source; \ No newline at end of file diff --git a/developer/src/server/src/win32/trayicon/addon-src/package-lock.json b/developer/src/server/src/win32/trayicon/addon-src/package-lock.json index 3b320611f1..f1e2d400b8 100644 --- a/developer/src/server/src/win32/trayicon/addon-src/package-lock.json +++ b/developer/src/server/src/win32/trayicon/addon-src/package-lock.json @@ -88,9 +88,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dependencies": { "balanced-match": "^4.0.2" }, @@ -540,9 +540,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "engines": { "node": ">=12" }, @@ -769,9 +769,9 @@ } }, "brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "requires": { "balanced-match": "^4.0.2" } @@ -1099,9 +1099,9 @@ } }, "picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==" }, "proc-log": { "version": "6.1.0", diff --git a/developer/src/test/auto/model-ts-parser/Keyman.System.Test.LexicalModelParserTest.pas b/developer/src/test/auto/model-ts-parser/Keyman.System.Test.LexicalModelParserTest.pas index 11dcd5a4a9..b26fb863f8 100644 --- a/developer/src/test/auto/model-ts-parser/Keyman.System.Test.LexicalModelParserTest.pas +++ b/developer/src/test/auto/model-ts-parser/Keyman.System.Test.LexicalModelParserTest.pas @@ -91,12 +91,17 @@ var begin lm := TLexicalModelParser.Create(m.Text); try + // TODO: LexicalModelParser does not support `rootClass` which is needed for + // custom lexical models, and the property `Wordlists` is inappropriate for + // the list of sources files. This is part of a bigger project for better + // custom model support in TIKE + // + // For now, this test excludes the `rootClass` property in order to pass lm.Comment := 'Testing'; lm.Format := lmfCustom10; lm.WordBreaker := lmwbAscii; lm.Wordlists.Clear; - lm.Wordlists.Add('foo.tsv'); - lm.Wordlists.Add('bar.tsv'); + lm.Wordlists.Add('CustomModel.ts'); Assert.AreEqual(mcustom.Text.Trim, lm.Text.Trim); // Ignoring whitespace before/after finally lm.Free; diff --git a/developer/src/test/auto/model-ts-parser/assets/custom.model.ts b/developer/src/test/auto/model-ts-parser/assets/custom.model.ts index 64e8f8e122..c22dfe0a1a 100644 --- a/developer/src/test/auto/model-ts-parser/assets/custom.model.ts +++ b/developer/src/test/auto/model-ts-parser/assets/custom.model.ts @@ -2,7 +2,7 @@ const source: LexicalModelSource = { format: 'custom-1.0', wordBreaker: 'ascii', - sources: ['foo.tsv', 'bar.tsv'] + sources: ['CustomModel.ts'] }; export default source; diff --git a/linux/docs/internal/gtk-input-methods.md b/linux/docs/internal/gtk-input-methods.md new file mode 100644 index 0000000000..f340d72268 --- /dev/null +++ b/linux/docs/internal/gtk-input-methods.md @@ -0,0 +1,132 @@ +# Input Methods in GTK + +This document describes how Keyman is connected to a client app, in other words +how a keypress in the app ends up being processed by the Keyman engine. + +For text input, GTK (or rather the app) will add input method support to a widget. +How the input method gets loaded depends on the GTK version. + +IBus provides input modules for GTK 2, GTK 3, GTK 4, X11, and Wayland. +However, the Wayland one is not used with Gnome because GTK provides +its own implementation. + +Which input modules are available depends on the GTK version. Apps that +don't use the Gnome/GTK framework use different ways for input method +support. + +## GTK 4 + +```mermaid +flowchart LR + subgraph GTK + app(application) + im-module["im-ibus + (IBusIMContext)"] + app --- im-module + end + + subgraph ibus[ibus-daemon] + direction LR + IBusInputContext <--> IBusEngine + end + + subgraph keyman + ibus-engine-keyman + end + + GTK <--> ibus <--> keyman + + style app color:#FFFFFF, stroke:#00C853, fill:#00C853 +``` + +The im-module implements a [GIOExtensionPoint](https://docs.gtk.org/gio/struct.IOExtensionPoint.html) +"gtk-im-module". The type extends `GtkIMContext`. +When loading the app, GTK dynamically loads and starts the extension +specified by the environment variable `GTK_IM_MODULE` (or the default which is +probably the one with the highest priority). + +External modules are located in `/usr/lib/x86_64-linux-gnu/gtk-4.0/4.0.0/immodules` +and named `lib*.so`. + +Each module has a priority, a name and the type that implements it. + +GTK4 has built-in support for Wayland (`GtkIMContextWayland`), Broadway +(`GtkIMContextBroadway`, irrelevant for Keyman) and gtk-im-context-simple +(`GtkIMContextSimple`, irrelevant for Keyman). `GtkIMContextWayland` is +implemented as part of GTK 4, the source code can be found in +. + +The input method for ibus is implemented as an external module +(type `IBusIMContext`). Source code is in +. + +The IBus communication between `IBusIMContext`, ibus-daemon and the ibus +engine (aka Keyman) happens with DBus messages on a separate IBus DBus +instance. + +## GTK 2/GTK 3 + +With older GTK versions basically the same components are involved, +although a different mechanism to discover and load the +im-modules is used. + +GTK caches the available modules in a module database located in +`/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules.cache` or whereever +the `GTK_IM_MODULE_FILE` environment variable points. This database is +read on application startup. + +For each module the database contains an id, the name, the path, i18n domain(?) +and default locales. + +GTK then determines what input module to load based on the `GTK_IM_MODULE` +environment variable (which can contain more than one module separated by +colons) or the `XSETTINGS` `gtk-im-module` property. It then checks that +the currently running display type is compatible with the module, and that +the current locale matches the locales supported by the module. An exact match +(`en_US` vs `en_US`) gets 4 points, a match of the language (`en_US` vs `en`) +gets 3 points, a match with a different region (`en_US` vs `en_UK`) 2 points, +and a wildcard (`en_US` vs `*`) 1 point. + +Each input module implements a subclass of `GTKIMContext` plus the 4 required +entry points: + +- `im_module_init()` - Initialize module +- `im_module_list()` - Describe available contexts +- `im_module_create()` - Create context instance +- `im_module_exit()` - Cleanup on unload + +GTK uses `GModule` to dynamically load the module's `.so` file and then calls +`im_module_init()` on the module, followed by `im_module_create()` to instantiate +the actual `GtkIMContext` subclass. + +If no module matches or loading fails, GTK falls back to `GtkIMContextSimple`. + +GTK3 provides support for several input methods, more than with GTK4. Those +can be found in . +Among the built-in modules are `im-wayland` (for Wayland), `im-xim` (for X11), +`im-thai` (for Thai), and `im-ime` (for Windows). + +The input method for ibus is implemented as an external module +(type `IBusIMContext`). Source code is in +. + +All modules are located in `/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules` +and named `*.so`, e.g. `im-ibus.so`. + +After installing a new module the database needs to be updated with +`sudo /usr/lib/x86_64-linux-gnu/libgtk-3-0t64/gtk-query-immodules-3.0 --update-cache`. + +## Tips + +- GTK has a built-in + [inspection/debugging tool](https://developer.gnome.org/documentation/tools/inspector.html) + which can be opened with Ctrl+Shift+I, or + Ctrl+Shift+D, or by setting the + environment variable `GTK_DEBUG=interactive`. +- Available GTK3 IM modules can be listed with + `/usr/lib/x86_64-linux-gnu/libgtk-3-0t64/gtk-query-immodules-3.0` or by + looking at `/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules.cache`. + +## Links + +- [GTK4 Input Handling](https://docs.gtk.org/gtk4/input-handling.html) \ No newline at end of file diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index dd9a8a3cee..9cf29e5b89 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -8,6 +8,7 @@ #import "KMInputMethodEventHandler.h" #import #import /* For kVK_ constants. */ +#import #import "KeySender.h" #import "TextApiCompliance.h" #import "KMSettingsRepository.h" @@ -370,7 +371,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; contextString = attributedString.string; //only uncomment for testing as we do not want to write context in logs - //os_log_debug([KMLogs testLog], " length: %lu result: %{public}@", contextString.length, contextString); + //os_log_debug([KMLogs keyTraceLog], " length: %lu result: %{public}@", contextString.length, contextString); } } } @@ -405,18 +406,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; [self insertAndReplaceTextForOutput:output client:client]; } else if (output.isDeleteOnlyScenario) { - if ((event.keyCode == kVK_Delete) && output.codePointsToDeleteBeforeInsert == 1) { - // let the delete pass through in the original event rather than sending a new delete - NSString *message = @"applyOutputToTextInputClient, delete only scenario with passthrough"; - os_log_debug([KMLogs keyTraceLog], "%@", message); - [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - handledEvent = NO; - } else { - NSString *message = @"applyOutputToTextInputClient, delete only scenario"; - os_log_debug([KMLogs keyTraceLog], "%@", message); - [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - [self sendEvents:event forOutput:output]; - } + handledEvent = [self handleDeleteOnlyScenario:output keyDownEvent:event client:client]; } else if (output.isDeleteAndInsertScenario) { // TODO: fix issue #10246 /* @@ -511,6 +501,220 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; } } +/** + * Handles deleting without an associated insert in one of three methods: + * 1. delete via replace: do the delete by replacing two (or more) characters with one. + * 2. backspace passthrough : if the original keydown event was a backspace, pass it through unhandled + * 3. generate event: generate keydown backspace events as necessary + */ +-(BOOL)handleDeleteOnlyScenario:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { + + // attempt to delete by replacing -- for compliant apps only + if ([self handleDeleteWithReplacement:output keyDownEvent:event client:client]) { + return YES; + } + + // pass through if this was a backspace keydown event + if (event.keyCode == kVK_Delete && output.codePointsToDeleteBeforeInsert == 1) { + // let the delete pass through in the original event rather than sending a new delete + NSString *message = @"handleDeleteOnlyForOutput, delete only scenario with passthrough"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + + // instruct system to handle the event + return NO; + } + else { + // otherwise generate a backspace + NSString *message = @"handleDeleteOnlyForOutput, send backspace event"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + [self sendEvents:event forOutput:output]; + + return YES; + } +} + +/** + * For compliant apps only. + * + * This an attempt to make sure that deletion removes only the expected codepoints. + * When handling a transform which only deletes a character, or when allowing a + * backspace to pass through, the OS or application may not use the same rules + * around deletion as Keyman -- especially when deleting clusters such as letter + + * combining diacritic (e.g. `U+0062 U+0301`), where some applications may delete + * both together as they represent a single 'grapheme cluster'. + * + * (Note, the question of whether it is appropriate for backspace to delete a + * cluster rather than a codepoint from an end-user perspective is not relevant + * here, because what is important is that we match the rules that the keyboard has + * provided, which means we need a method of deleting a precise number of + * codepoints. The keyboard author can and should include rules for cluster + * deletion that meet end-user expectations.) + * + * The `insertText` API takes two parameters: a string to insert, and a range to + * replace with that string. However, we cannot simply pass through a zero-length + * insertion string along with the range to delete, because the `insertText` API + * treats this as an invalid call and ignores it. + * + * Instead, we can delete the desired number of codepoints only by using the + * `insertText` API to replace e.g. two codepoints with one. + * + * This method only works for compliant apps because non-compliant apps do not + * support the `insertText` API. + * + * Ref: https://developer.apple.com/documentation/appkit/nstextinputclient/inserttext(_:replacementrange:) + */ +-(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { + BOOL handledEvent = NO; + NSString *context = [self readContext:event forClient:client]; + + // guard: only for compliant apps with sufficient context + if (!(self.apiCompliance.canReplaceText) || ([context length] <= output.textToDelete.length)) { + os_log_debug([KMLogs keyTraceLog], "cannot replace text, non-compliant or insufficient context"); + return NO; // return without deleting/replacing + } + + // guard: the logic of this method depends on locating textToDelete in the context + if (![self stringToDeleteMatchesContextSuffix:output.textToDelete context:context]) { + os_log_debug([KMLogs keyTraceLog], "cannot replace text, textToDelete not found at end of context"); + return NO; // return without deleting/replacing + } + + NSUInteger deletionTargetLength = output.textToDelete.length; + NSUInteger deletionTargetLocation = context.length-deletionTargetLength; + NSUInteger precedingCharacterLocation = deletionTargetLocation - 1; + + // if the preceding character is the trailing half of a surrogate pair + // then delete by replacing with the entire surrogate pair + if ([self precededBySurrogatePair:precedingCharacterLocation context:context]) { + handledEvent = [self deleteByReplacingWithPrecedingSurrogate:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; + } else { + // otherwise replace with only the preceding character + handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; + } + + return handledEvent; +} + +/** + * Check whether the string to be deleted is found at the tail end of the current context. + */ +-(BOOL) stringToDeleteMatchesContextSuffix:(NSString*)textToDelete context:(NSString*) context { + BOOL doesMatch = NO; + + // get length of string to delete and compare to end of context + NSUInteger deleteLength = textToDelete.length; + NSUInteger locationOfDeletionTarget = context.length-deleteLength; + NSUInteger locationOfPrecedingCharacter = locationOfDeletionTarget - 1; + NSString *contextSuffix = [context substringFromIndex:context.length-deleteLength]; + + os_log_debug([KMLogs keyTraceLog], "stringToDeleteMatchesSuffix, textToDelete: '%{public}@', contextSuffix: '%{public}@', locationOfDeletionTarget: %u, locationOfprecedingCharacter: %u", textToDelete, contextSuffix, (int)locationOfDeletionTarget, (int)locationOfPrecedingCharacter); + + doesMatch = [textToDelete isEqualToString:contextSuffix]; + os_log_debug([KMLogs keyTraceLog], "stringToDeleteMatchesSuffix: %{public}@", doesMatch?@"YES":@"NO"); + return doesMatch; +} + +/** + * Check whether the preceding character, which is to be used for the replacement, + * is part of a surrogate pair that is distinct from the character being deleted. + */ +-(BOOL) precededBySurrogatePair: (NSUInteger)precedingLocation context:(NSString*) context { + BOOL precedingCharacterIsLowSurrogate = false; + unichar precedingCharacter = [context characterAtIndex:precedingLocation]; + + if (CFStringIsSurrogateHighCharacter(precedingCharacter)) { + // preceding character is high character -- unexpected from Keyman Core + // write to log and return false + precedingCharacterIsLowSurrogate = false; + NSString *message = [NSString stringWithFormat:@"Unexpected high surrogate found for preceding character at %ld", (long)precedingCharacter]; + os_log_debug([KMLogs keyTraceLog], "%{public}@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + } else if (CFStringIsSurrogateLowCharacter(precedingCharacter)) { + // preceding character is low surrogate + precedingCharacterIsLowSurrogate = true; + } + + return precedingCharacterIsLowSurrogate; +} + +/** + * Replace both the text to delete and the character preceding it solely with the character that precedes it. + * Returns YES if executing the replace/delete and NO otherwise. + */ +-(BOOL) deleteByReplacingWithPrecedingCharacter:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { + + os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCharacter"); + // get the preceding character + NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation, 1); + NSString *replacementString = [context substringWithRange:precedingCharacterRange]; + + // guard: if preceding character is a control character, return NO + if ([self containsControlCharacter:replacementString]) { + NSString *message = @"replacementString contains control characters, cannot delete with replace"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + return NO; + } + + // perform the replacement + NSUInteger replacementLength = [replacementString length] + deleteLength; + NSRange replacementRange = NSMakeRange(precedingCharacterLocation, replacementLength); + os_log_debug([KMLogs keyTraceLog], "replacementRange: %{public}@", NSStringFromRange(replacementRange)); + [client insertText:replacementString replacementRange:replacementRange]; + + return YES; +} + +/** + * Replace both the text to delete and the surrogate pair preceding it with the surrogate pair preceding it. + * Returns YES if executing the replace/delete and NO otherwise. + */ +-(BOOL) deleteByReplacingWithPrecedingSurrogate:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { + + os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingSurrogate"); + + // guard: return NO if there is no character before the precedingCharacterLocation + if (precedingCharacterLocation <= 0) { + NSString *message = @"no characters exist before precedingCharacterLocation, so it cannot be a surrogate pair"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + return NO; + } + + // get the preceding character + NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation - 1, 2); + NSString *replacementString = [context substringWithRange:precedingCharacterRange]; + + unichar highCharacter = [replacementString characterAtIndex:0]; + unichar lowCharacter = [replacementString characterAtIndex:1]; + + // verify that preceding characters comprise a surrogate pair + if ((CFStringIsSurrogateHighCharacter(highCharacter)) && (CFStringIsSurrogateLowCharacter(lowCharacter))) { + // found preceding surrogate as expected + } else { + NSString *message = [NSString stringWithFormat:@"Preceding characters of string do not comprise a surrogate pair: 0x%02x, 0x%02x", (unsigned int)highCharacter, (unsigned int)lowCharacter]; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + return NO; + } + + // perform the replacement + NSUInteger replacementLength = [replacementString length] + deleteLength; + NSRange replacementRange = NSMakeRange(precedingCharacterRange.location, replacementLength); + os_log_debug([KMLogs keyTraceLog], "replacementRange: %{public}@", NSStringFromRange(replacementRange)); + [client insertText:replacementString replacementRange:replacementRange]; + + return YES; +} + +-(BOOL) containsControlCharacter:(NSString*)text { + NSCharacterSet *controlSet = [NSCharacterSet controlCharacterSet]; + NSRange range = [text rangeOfCharacterFromSet:controlSet]; + + return (range.location != NSNotFound); +} + /** * Calculates the range where text will be inserted and replace existing text. * Returning {NSNotFound, NSNotFound} for range signifies to insert at current location without replacement. diff --git a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h index 1f5b55f17f..40c516a8db 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h @@ -22,6 +22,7 @@ NS_ASSUME_NONNULL_BEGIN -(void) checkComplianceAfterInsert:(NSString *)insertedText deleted:(NSString *)deletedText; -(BOOL)isComplianceUncertain; -(BOOL)canReadText; +-(BOOL)canReplaceText; -(BOOL)mustBackspaceUsingEvents; @end diff --git a/package-lock.json b/package-lock.json index c05360af53..f9c71867b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4823,15 +4823,6 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -5955,15 +5946,6 @@ "version": "1.0.3", "license": "MIT" }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7963,7 +7945,9 @@ } }, "node_modules/flatted": { - "version": "3.2.5", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -9882,15 +9866,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/make-fetch-happen": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", @@ -11288,10 +11263,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -12895,47 +12871,6 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-node": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz", - "integrity": "sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "source-map-support": "^0.5.17", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "typescript": ">=2.7" - } - }, - "node_modules/ts-node/node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/tsc-watch": { "version": "4.6.2", "dev": true, @@ -13772,18 +13707,6 @@ "node": ">= 4.0.0" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/resources/build/pr-build-status/pr-build-status.prefix.yml b/resources/build/pr-build-status/pr-build-status.prefix.yml index cbeba112a1..d82aa9cb77 100644 --- a/resources/build/pr-build-status/pr-build-status.prefix.yml +++ b/resources/build/pr-build-status/pr-build-status.prefix.yml @@ -32,6 +32,6 @@ jobs: steps: - name: Check PR build status id: run_pr_build_status_script - uses: actions/github-script@v7 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | diff --git a/resources/teamcity/includes/tc-helpers.inc.sh b/resources/teamcity/includes/tc-helpers.inc.sh index b4c62826e1..6f704ef1f4 100644 --- a/resources/teamcity/includes/tc-helpers.inc.sh +++ b/resources/teamcity/includes/tc-helpers.inc.sh @@ -65,8 +65,26 @@ _tc_rsync() { else local RSYNC=rsync if builder_is_macos; then + # We need to look for a newer version of rsync because the version bundled + # with macos does not work correctly; we assume that this is installed + # with Homebrew. On Intel, this is found in /usr/local/bin, and on M1, it + # is found in /opt/homebrew/bin. + RSYNC=/usr/local/bin/rsync - [[ -f /opt/homebrew/bin/rsync ]] && RSYNC=/opt/homebrew/bin/rsync + [[ -x /opt/homebrew/bin/rsync ]] && RSYNC=/opt/homebrew/bin/rsync + + # On build agents, we unlink rsync from homebrew, because otherwise xcode + # builds which use rsync internally fail, so we need to specify the full + # path to rsync. Generally, rsync is not needed on developer machines as + # this is only used for release builds from the build agents. #15764 + if [[ ! -x "${RSYNC}" ]]; then + # Try and find the rsync binary with `brew list`. It should be something + # like `/opt/homebrew/Cellar/rsync/3.4.1/bin/rsync` + RSYNC="$(brew list rsync | grep /bin/rsync\$)" + if [[ ! -x "${RSYNC}" ]]; then + builder_die "Could not find Homebrew-installed rsync anywhere" + fi + fi fi ${RSYNC} "${rsync_args[@]}" diff --git a/resources/teamcity/includes/tc-mac.inc.sh b/resources/teamcity/includes/tc-mac.inc.sh index 0e9c18013c..a138789cf5 100644 --- a/resources/teamcity/includes/tc-mac.inc.sh +++ b/resources/teamcity/includes/tc-mac.inc.sh @@ -12,9 +12,13 @@ ba_mac_unlock_keychain() { } ba_mac_clean_xcode_derived_data() { - builder_echo start "clean" "Cleaning XCode DerivedData mess" + builder_echo start "clean" "Cleaning XCode DerivedData and Carthage cache mess" rm -rf "${HOME}/Library/Developer/Xcode/DerivedData" - builder_echo end "clean" success "Finished cleaning XCode DerivedData mess" + + # https://stackoverflow.com/a/45504898/1836776 + rm -rf "${HOME}/Library/Caches/org.carthage.CarthageKit" + + builder_echo end "clean" success "Finished cleaning XCode DerivedData and Carthage cache mess" } ba_mac_unmount_volumes_keyman() {