Skip to content

Fix keyDown event handling for macOS input methods - #21906

Merged
MrJul merged 6 commits into
AvaloniaUI:mainfrom
Gillibald:fix/macos-always-raise-keydown
Aug 19, 2026
Merged

Fix keyDown event handling for macOS input methods#21906
MrJul merged 6 commits into
AvaloniaUI:mainfrom
Gillibald:fix/macos-always-raise-keydown

Conversation

@Gillibald

Copy link
Copy Markdown
Contributor

What does the pull request do?

Makes every keystroke observable to user code on macOS, matching Win32. AvnView used to give each keyDown to the NSTextInputContext first and only raised a KeyDown when the context did not consume it, but the context consumes every printable key while a text input client is active. Now exactly one KeyDown is raised per NSEvent before the context sees it, and keystrokes that belong to the input method are masked as Key.ImeProcessed, the way Win32 reports VK_PROCESSKEY. Also adds a Keyboard page to IntegrationTestApp and Appium tests for both the Windows and macOS legs.

What is the current behavior?

What is the updated/expected behavior with this PR?

  • One KeyDown per NSEvent; gestures on printable keys match while a TextBox is focused, and a handled KeyDown produces no text.
  • During a composition, and for the printable key that may start one while a composing input method is selected, KeyDown reports Key.ImeProcessed; PhysicalKey and KeySymbol keep their real values.
  • Backspace, Forward Delete, Enter, Tab, Escape, arrows, and Command/Control combinations are never masked outside a composition.

To test: run the Appium KeyboardTests, or use the IntegrationTestApp Keyboard page: gestures fire on a plain layout without inserting text, typing under a Japanese/Chinese input method shows ImeProcessed|<physical>|[<symbol>] while composition works normally. Validated on macOS arm64 with Kana input, including forward delete and focus loss mid-composition.

How was the solution implemented (if it's not obvious)?

  • Mask condition: active input client, and either marked text exists or the key can start a composition (CanStartComposition) while the selected input source composes.
  • CanStartComposition rejects keys whose symbol is a control character or DEL (0x7F), so editing keys stay real.
  • Composing-source detection via TIS: not a plain keyboard layout, excluding the alphanumeric mode (com.apple.inputmethod.Roman); cached per input source id; unknown sources keep the real key.
  • The old handleKeyDown: re-raise path through doCommandBySelector: is removed; the empty override stays to avoid the NSResponder beep.
  • Only Command and Control count as shortcut modifiers; Option produces text on macOS, so it stays maskable. Intentional divergence from Windows Alt.

Checklist

  • Added unit tests (if possible)? Appium KeyboardTests, running on the Windows leg too.
  • Added XML documentation to any related classes? No public API changed.
  • Consider submitting a PR to https://github.com/AvaloniaUI/avalonia-docs with user documentation

Breaking changes

No API changes. Behavioral, macOS only:

  • KeyDown now fires for keystrokes that previously raised none while a text input client was active; during composition, it reports Key.ImeProcessed.
  • Enter no longer raises a duplicate KeyDown.
  • A handled KeyDown no longer inserts text.

Obsoletions / Deprecations

None.

Fixed issues

Fixes #20255

AvnView passed every keyDown to the NSTextInputContext first and only
raised a KeyDown when the context did not consume the event. The context
consumes every printable key by routing it to insertText:, so while a
text input client was active no KeyDown was raised for space, letters or
digits and a KeyGesture on those keys could never match. Only modified
keys were special-cased.

keyDown: now always raises exactly one KeyDown per NSEvent before the
input context is consulted. Composition state only decides which key is
reported: while hasMarkedText is set the key is masked as
AvnKeyImeProcessed, the same way Win32 reports VK_PROCESSKEY, so user
code still observes the event but no gesture matches it. The physical key
and the key symbol keep their real values in both cases.

If user code handles the event the input context is skipped entirely, so
no text and no preedit is produced. This mirrors the Win32 behaviour of
swallowing WM_CHAR after a handled WM_KEYDOWN.

The modifier special case, the handleKeyDown: helper and
_lastKeyDownEvent are gone. doCommandBySelector: is kept but empty, since
falling back to NSResponder would perform the default action and beep.

Adds a Keyboard page to IntegrationTestApp that reports the last key
down, a key down count and the last text input, plus Appium tests
covering gestures with and without a modifier, text suppression for a
handled gesture, and a single key down per key press.
…hods

hasMarkedText only becomes true once a composition is in progress, so the
keystroke that starts one was still reported with its real key and could
match a KeyGesture. Win32 reports VK_PROCESSKEY for that keystroke too.

keyDown: now also masks the key as AvnKeyImeProcessed when a printable
character key is typed into a text input client while the selected
keyboard input source is a composing input method.

Whether an input source composes is resolved through
TISGetInputSourceProperty: anything that is not a plain keyboard layout
composes, except an input method in the alphanumeric input mode, which
passes keys straight through and therefore keeps the real key. Unknown
input sources keep the real key as well, so gestures are never lost to a
failed lookup. The lookup includes installed but not enabled input
sources, since an input method mode can be selected without being listed
as enabled, and the result is cached per input source id because this runs
for every key down.

Only printable character keys are masked. KeySymbolFromScanCode reports
the control character for Backspace, Enter, Tab and Escape, so the
presence of a key symbol is not enough to decide: masking those would stop
TextBox from reacting to them, which broke Backspace while an input method
was selected. Command and Control combinations are shortcuts and are not
masked either.

Plain keyboard layouts are unaffected: dead key compositions are not
driven by the input source and stay covered by hasMarkedText.

The Keyboard page now subscribes to KeyDown and TextInput with
handledEventsToo, so keys TextBox handles in its class handler, such as
Backspace and the arrows, stay visible in the readout.
@Gillibald Gillibald added customer-priority Issue reported by a customer with a support agreement. os-macos labels Jul 31, 2026
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0068010-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@Gillibald

Copy link
Copy Markdown
Contributor Author

Integration tests ran just fine on my Mac. It looks like CI is using a different driver.

@MrJul MrJul added the backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch label Aug 4, 2026
@MrJul MrJul removed the backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch label Aug 4, 2026
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0068095-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

MrJul
MrJul previously approved these changes Aug 6, 2026

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested with various keyboard layouts and IMEs, it works as expected.
This is a nice improvement, key handling has been a pain point in our macOS backend forever.

@MrJul
MrJul enabled auto-merge August 6, 2026 15:57
@MrJul MrJul added the backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch label Aug 6, 2026
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0068163-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul
MrJul disabled auto-merge August 7, 2026 10:24
@MrJul

MrJul commented Aug 7, 2026

Copy link
Copy Markdown
Member

macOS integration test failures aren't a fluke.

An unknown server-side error occurred while processing the command. Original error: Error Domain=io.appium.WebDriverAgentMac Code=1 "Only actions of '(pointer)' types are supported. 'key' is given instead for action with id 'default keyboard'"

Probably something not compatible with Appium v1.

The tests synthesize key input through W3C actions, which the mac2
driver bundled with Appium 1 does not implement, so on the macOS CI leg
they could only fail. They keep running on Windows, where WinAppDriver
handles W3C actions, and on macOS builds with IsRunningAppium2, which
target a current mac2 driver. The infrastructure change that would run
them on macOS CI is split out for a separate pull request.
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0068491-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul
MrJul added this pull request to the merge queue Aug 19, 2026
Merged via the queue into AvaloniaUI:main with commit 29bd4a2 Aug 19, 2026
10 checks passed
@Gillibald Gillibald added backported-11.3.x backport-candidate-11.3.x Consider this PR for backporting to 11.3 branch and removed backported-11.3.x labels Aug 31, 2026
MrJul added a commit to MrJul/Avalonia that referenced this pull request Sep 2, 2026
* fix(macOS): always raise keyDown before the input context sees the event

AvnView passed every keyDown to the NSTextInputContext first and only
raised a KeyDown when the context did not consume the event. The context
consumes every printable key by routing it to insertText:, so while a
text input client was active no KeyDown was raised for space, letters or
digits and a KeyGesture on those keys could never match. Only modified
keys were special-cased.

keyDown: now always raises exactly one KeyDown per NSEvent before the
input context is consulted. Composition state only decides which key is
reported: while hasMarkedText is set the key is masked as
AvnKeyImeProcessed, the same way Win32 reports VK_PROCESSKEY, so user
code still observes the event but no gesture matches it. The physical key
and the key symbol keep their real values in both cases.

If user code handles the event the input context is skipped entirely, so
no text and no preedit is produced. This mirrors the Win32 behaviour of
swallowing WM_CHAR after a handled WM_KEYDOWN.

The modifier special case, the handleKeyDown: helper and
_lastKeyDownEvent are gone. doCommandBySelector: is kept but empty, since
falling back to NSResponder would perform the default action and beep.

Adds a Keyboard page to IntegrationTestApp that reports the last key
down, a key down count and the last text input, plus Appium tests
covering gestures with and without a modifier, text suppression for a
handled gesture, and a single key down per key press.

* fix(macOS): mask the composition starting key for composing input methods

hasMarkedText only becomes true once a composition is in progress, so the
keystroke that starts one was still reported with its real key and could
match a KeyGesture. Win32 reports VK_PROCESSKEY for that keystroke too.

keyDown: now also masks the key as AvnKeyImeProcessed when a printable
character key is typed into a text input client while the selected
keyboard input source is a composing input method.

Whether an input source composes is resolved through
TISGetInputSourceProperty: anything that is not a plain keyboard layout
composes, except an input method in the alphanumeric input mode, which
passes keys straight through and therefore keeps the real key. Unknown
input sources keep the real key as well, so gestures are never lost to a
failed lookup. The lookup includes installed but not enabled input
sources, since an input method mode can be selected without being listed
as enabled, and the result is cached per input source id because this runs
for every key down.

Only printable character keys are masked. KeySymbolFromScanCode reports
the control character for Backspace, Enter, Tab and Escape, so the
presence of a key symbol is not enough to decide: masking those would stop
TextBox from reacting to them, which broke Backspace while an input method
was selected. Command and Control combinations are shortcuts and are not
masked either.

Plain keyboard layouts are unaffected: dead key compositions are not
driven by the input source and stay covered by hasMarkedText.

The Keyboard page now subscribes to KeyDown and TextInput with
handledEventsToo, so keys TextBox handles in its class handler, such as
Backspace and the arrows, stay visible in the readout.

* test(macOS): skip the Keyboard tests on the Appium 1 leg

The tests synthesize key input through W3C actions, which the mac2
driver bundled with Appium 1 does not implement, so on the macOS CI leg
they could only fail. They keep running on Windows, where WinAppDriver
handles W3C actions, and on macOS builds with IsRunningAppium2, which
target a current mac2 driver. The infrastructure change that would run
them on macOS CI is split out for a separate pull request.

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Sep 2, 2026
MrJul added a commit that referenced this pull request Sep 7, 2026
* fix(macOS): always raise keyDown before the input context sees the event

AvnView passed every keyDown to the NSTextInputContext first and only
raised a KeyDown when the context did not consume the event. The context
consumes every printable key by routing it to insertText:, so while a
text input client was active no KeyDown was raised for space, letters or
digits and a KeyGesture on those keys could never match. Only modified
keys were special-cased.

keyDown: now always raises exactly one KeyDown per NSEvent before the
input context is consulted. Composition state only decides which key is
reported: while hasMarkedText is set the key is masked as
AvnKeyImeProcessed, the same way Win32 reports VK_PROCESSKEY, so user
code still observes the event but no gesture matches it. The physical key
and the key symbol keep their real values in both cases.

If user code handles the event the input context is skipped entirely, so
no text and no preedit is produced. This mirrors the Win32 behaviour of
swallowing WM_CHAR after a handled WM_KEYDOWN.

The modifier special case, the handleKeyDown: helper and
_lastKeyDownEvent are gone. doCommandBySelector: is kept but empty, since
falling back to NSResponder would perform the default action and beep.

Adds a Keyboard page to IntegrationTestApp that reports the last key
down, a key down count and the last text input, plus Appium tests
covering gestures with and without a modifier, text suppression for a
handled gesture, and a single key down per key press.

* fix(macOS): mask the composition starting key for composing input methods

hasMarkedText only becomes true once a composition is in progress, so the
keystroke that starts one was still reported with its real key and could
match a KeyGesture. Win32 reports VK_PROCESSKEY for that keystroke too.

keyDown: now also masks the key as AvnKeyImeProcessed when a printable
character key is typed into a text input client while the selected
keyboard input source is a composing input method.

Whether an input source composes is resolved through
TISGetInputSourceProperty: anything that is not a plain keyboard layout
composes, except an input method in the alphanumeric input mode, which
passes keys straight through and therefore keeps the real key. Unknown
input sources keep the real key as well, so gestures are never lost to a
failed lookup. The lookup includes installed but not enabled input
sources, since an input method mode can be selected without being listed
as enabled, and the result is cached per input source id because this runs
for every key down.

Only printable character keys are masked. KeySymbolFromScanCode reports
the control character for Backspace, Enter, Tab and Escape, so the
presence of a key symbol is not enough to decide: masking those would stop
TextBox from reacting to them, which broke Backspace while an input method
was selected. Command and Control combinations are shortcuts and are not
masked either.

Plain keyboard layouts are unaffected: dead key compositions are not
driven by the input source and stay covered by hasMarkedText.

The Keyboard page now subscribes to KeyDown and TextInput with
handledEventsToo, so keys TextBox handles in its class handler, such as
Backspace and the arrows, stay visible in the readout.

* test(macOS): skip the Keyboard tests on the Appium 1 leg

The tests synthesize key input through W3C actions, which the mac2
driver bundled with Appium 1 does not implement, so on the macOS CI leg
they could only fail. They keep running on Windows, where WinAppDriver
handles W3C actions, and on macOS builds with IsRunningAppium2, which
target a current mac2 driver. The infrastructure change that would run
them on macOS CI is split out for a separate pull request.

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
MrJul added a commit that referenced this pull request Sep 7, 2026
* fix(macOS): always raise keyDown before the input context sees the event

AvnView passed every keyDown to the NSTextInputContext first and only
raised a KeyDown when the context did not consume the event. The context
consumes every printable key by routing it to insertText:, so while a
text input client was active no KeyDown was raised for space, letters or
digits and a KeyGesture on those keys could never match. Only modified
keys were special-cased.

keyDown: now always raises exactly one KeyDown per NSEvent before the
input context is consulted. Composition state only decides which key is
reported: while hasMarkedText is set the key is masked as
AvnKeyImeProcessed, the same way Win32 reports VK_PROCESSKEY, so user
code still observes the event but no gesture matches it. The physical key
and the key symbol keep their real values in both cases.

If user code handles the event the input context is skipped entirely, so
no text and no preedit is produced. This mirrors the Win32 behaviour of
swallowing WM_CHAR after a handled WM_KEYDOWN.

The modifier special case, the handleKeyDown: helper and
_lastKeyDownEvent are gone. doCommandBySelector: is kept but empty, since
falling back to NSResponder would perform the default action and beep.

Adds a Keyboard page to IntegrationTestApp that reports the last key
down, a key down count and the last text input, plus Appium tests
covering gestures with and without a modifier, text suppression for a
handled gesture, and a single key down per key press.

* fix(macOS): mask the composition starting key for composing input methods

hasMarkedText only becomes true once a composition is in progress, so the
keystroke that starts one was still reported with its real key and could
match a KeyGesture. Win32 reports VK_PROCESSKEY for that keystroke too.

keyDown: now also masks the key as AvnKeyImeProcessed when a printable
character key is typed into a text input client while the selected
keyboard input source is a composing input method.

Whether an input source composes is resolved through
TISGetInputSourceProperty: anything that is not a plain keyboard layout
composes, except an input method in the alphanumeric input mode, which
passes keys straight through and therefore keeps the real key. Unknown
input sources keep the real key as well, so gestures are never lost to a
failed lookup. The lookup includes installed but not enabled input
sources, since an input method mode can be selected without being listed
as enabled, and the result is cached per input source id because this runs
for every key down.

Only printable character keys are masked. KeySymbolFromScanCode reports
the control character for Backspace, Enter, Tab and Escape, so the
presence of a key symbol is not enough to decide: masking those would stop
TextBox from reacting to them, which broke Backspace while an input method
was selected. Command and Control combinations are shortcuts and are not
masked either.

Plain keyboard layouts are unaffected: dead key compositions are not
driven by the input source and stay covered by hasMarkedText.

The Keyboard page now subscribes to KeyDown and TextInput with
handledEventsToo, so keys TextBox handles in its class handler, such as
Backspace and the arrows, stay visible in the readout.

* test(macOS): skip the Keyboard tests on the Appium 1 leg

The tests synthesize key input through W3C actions, which the mac2
driver bundled with Appium 1 does not implement, so on the macOS CI leg
they could only fail. They keep running on Windows, where WinAppDriver
handles W3C actions, and on macOS builds with IsRunningAppium2, which
target a current mac2 driver. The infrastructure change that would run
them on macOS CI is split out for a separate pull request.

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
MrJul added a commit that referenced this pull request Sep 7, 2026
* fix(macOS): always raise keyDown before the input context sees the event

AvnView passed every keyDown to the NSTextInputContext first and only
raised a KeyDown when the context did not consume the event. The context
consumes every printable key by routing it to insertText:, so while a
text input client was active no KeyDown was raised for space, letters or
digits and a KeyGesture on those keys could never match. Only modified
keys were special-cased.

keyDown: now always raises exactly one KeyDown per NSEvent before the
input context is consulted. Composition state only decides which key is
reported: while hasMarkedText is set the key is masked as
AvnKeyImeProcessed, the same way Win32 reports VK_PROCESSKEY, so user
code still observes the event but no gesture matches it. The physical key
and the key symbol keep their real values in both cases.

If user code handles the event the input context is skipped entirely, so
no text and no preedit is produced. This mirrors the Win32 behaviour of
swallowing WM_CHAR after a handled WM_KEYDOWN.

The modifier special case, the handleKeyDown: helper and
_lastKeyDownEvent are gone. doCommandBySelector: is kept but empty, since
falling back to NSResponder would perform the default action and beep.

Adds a Keyboard page to IntegrationTestApp that reports the last key
down, a key down count and the last text input, plus Appium tests
covering gestures with and without a modifier, text suppression for a
handled gesture, and a single key down per key press.

* fix(macOS): mask the composition starting key for composing input methods

hasMarkedText only becomes true once a composition is in progress, so the
keystroke that starts one was still reported with its real key and could
match a KeyGesture. Win32 reports VK_PROCESSKEY for that keystroke too.

keyDown: now also masks the key as AvnKeyImeProcessed when a printable
character key is typed into a text input client while the selected
keyboard input source is a composing input method.

Whether an input source composes is resolved through
TISGetInputSourceProperty: anything that is not a plain keyboard layout
composes, except an input method in the alphanumeric input mode, which
passes keys straight through and therefore keeps the real key. Unknown
input sources keep the real key as well, so gestures are never lost to a
failed lookup. The lookup includes installed but not enabled input
sources, since an input method mode can be selected without being listed
as enabled, and the result is cached per input source id because this runs
for every key down.

Only printable character keys are masked. KeySymbolFromScanCode reports
the control character for Backspace, Enter, Tab and Escape, so the
presence of a key symbol is not enough to decide: masking those would stop
TextBox from reacting to them, which broke Backspace while an input method
was selected. Command and Control combinations are shortcuts and are not
masked either.

Plain keyboard layouts are unaffected: dead key compositions are not
driven by the input source and stay covered by hasMarkedText.

The Keyboard page now subscribes to KeyDown and TextInput with
handledEventsToo, so keys TextBox handles in its class handler, such as
Backspace and the arrows, stay visible in the readout.

* test(macOS): skip the Keyboard tests on the Appium 1 leg

The tests synthesize key input through W3C actions, which the mac2
driver bundled with Appium 1 does not implement, so on the macOS CI leg
they could only fail. They keep running on Windows, where WinAppDriver
handles W3C actions, and on macOS builds with IsRunningAppium2, which
target a current mac2 driver. The infrastructure change that would run
them on macOS CI is split out for a separate pull request.

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
@MrJul MrJul added backported-11.3.x and removed backport-candidate-11.3.x Consider this PR for backporting to 11.3 branch labels Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backported-11.3.x backported-12.1.x bug customer-priority Issue reported by a customer with a support agreement. os-macos

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[macOS] Option+Left arrow key event not received in KeyDown handler (Option+Right works)

3 participants