Skip to content

Support Chinese IME preedit and fix related bugs - #592

Open
Timskt wants to merge 5 commits into
AvaloniaUI:masterfrom
Timskt:ime-preedit-and-fixes
Open

Support Chinese IME preedit and fix related bugs#592
Timskt wants to merge 5 commits into
AvaloniaUI:masterfrom
Timskt:ime-preedit-and-fixes

Conversation

@Timskt

@Timskt Timskt commented May 28, 2026

Copy link
Copy Markdown

Summary

Implements Chinese IME preedit (composition) support for TextArea, along with related bug fixes and improvements.

Changes

  • feat: Implement PreeditLayer for rendering IME preedit text with underline styling in TextArea
  • fix: Render preedit cursor at end of text when cursorOffset is not provided
  • fix: Resolve multiple bugs and improve command infrastructure
  • test: Add Chinese IME verification tests

Related

Test plan

  • Verify Chinese IME input works correctly with preedit display
  • Verify preedit cursor positioning
  • Verify IME does not interfere with child controls (search TextBox)
  • Run the IME verification tests

Timskt added 4 commits May 29, 2026 00:35
- Add PreeditLayer to render IME composition text at caret position with underline
- Enable SupportsPreedit in TextAreaTextInputMethodClient
- Implement SetPreeditText(string) and SetPreeditText(string, int?) overloads
- Clear preedit on text input, focus loss, Escape key, and document change
- Update preedit position when caret moves

Fixes Chinese IME input where composition text was not displayed.
- Add automated test suite verifying IME client setup
- Tests: SupportsPreedit, PreeditLayer, SetPreeditText, ClearPreedit
- All 8 tests pass
…rovided

When the platform calls SetPreeditText(string) without a cursorOffset,
the cursor was not rendered at all. Now defaults to rendering at the
end of the composition text, which matches standard IME behavior.
Changelog:

## Bug Fixes

### OnPaste document update lock (EditingCommandHandler.cs)
- The async void OnPaste method could leave the document in a permanent
  BeginUpdate state if ReplaceSelectionWithText threw an exception after
  the clipboard await. Wrapped the entire operation in try/finally to
  ensure EndUpdate is always called.
- Also added null checks for clipboard access to prevent NRE.

### ICommand.CanExecuteChanged never fires (RoutedCommand.cs)
- The CanExecuteChanged event had empty add/remove accessors, meaning
  UI elements bound to commands would never re-evaluate their enabled
  state. Implemented proper event storage and added CommandManager class
  with InvalidateRequerySuggested() to trigger re-evaluation.
- Focus changes now automatically trigger command re-evaluation.

### InvalidateCursor dead code (TextView.cs)
- The InvalidateCursor method scheduled a dispatcher callback that did
  nothing (MouseDevice.Instance.UpdateCursor() was commented out).
- Replaced with clean no-op implementation with documentation explaining
  that Avalonia handles cursor updates automatically via the Cursor
  property. Removed unused _invalidCursor field.

## CI Fixes

### Artifact name invalid for branches with slashes (build-nuget.yml)
- Branch names containing '/' (e.g. bugfixes/enhancements, feature/xxx)
  caused upload-artifact to fail. Added a sanitize step that replaces
  '/' with '-' in the artifact name.
@YamashitaY-TextSS

Copy link
Copy Markdown

Hi @Timsktyou found the way through first, and I was able to fix my own application by following your work. Thank you.

I would like to report that it also solves the problem for Japanese IME, with numbers, in case that is useful for review.

I maintain TextSS, a Japanese freeware bulk text-replacement tool that I have been releasing for 28 years. I recently ported it from VB.NET / .NET Framework to C# / .NET 10 + Avalonia, and AvaloniaEdit's missing preedit was the one blocker I could not solve: with Japanese input you cannot see what you are typing until you commit it, which is fatal for a Japanese tool. I released 6.00 with a workaround — a second, hand-made text box sitting beside the editor — rather than a fix.

Then I found this PR. It is written for Chinese IME, but the implementation is language-independent and worked for Japanese exactly as it is.

Environment

  • .NET 10, Avalonia 12.1.1, AvaloniaEdit master be976ea plus the preedit part of this PR
  • Windows 11 — Microsoft IME
  • macOS 26 (Apple Silicon) — Japanese - Romaji, both with and without Live Conversion
  • Linux Mint 22.3 Xfce — fcitx5 5.1.7 + fcitx5-mozc 2.28.4715.102

What works with this PR as it is — on all three platforms

  1. The composition is drawn inline at the caret, underlined, with a cursor inside it.
  2. The IME candidate window follows directly below the composition.
  3. The composition is not inserted into the document; committing inserts it once.
  4. Escape cancels the composition and it disappears.
  5. The composition is cleared on focus loss and when the document is replaced.
  6. The composition follows the caret when the caret moves.
  7. Multi-clause conversion, mid-line conversion, after horizontal scrolling, different font sizes, light and dark themes, word wrap on/off, line numbers on/off — all render in the right place.

Point 3 I checked numerically rather than by eye: while composing 9 characters at the end of a 58-character line, the status readout stayed at Ln 2, Col 59. Had the composition been inserted into the document it would have read Col 68.

Five things I had to add on top — none of them defects in this PR

Three of them are behaviour outside this PR's scope, and two only show up with the long, whole-sentence conversions that Japanese input produces. I list them because anyone adopting this PR for Japanese will meet them.

# What happens Why What I did
1 Clicking elsewhere while composing makes the uncommitted text follow the caret #534 — Avalonia only resets the IME on ResetRequested and on a client change, never on a caret move, so the composition stays alive and only its rendering moves Commit at the old caret position during the tunnel phase of the click, before the caret moves
2 Composition text past the right edge is clipped and cannot be read The composition is never inserted into the document, so it gets none of the wrapping or horizontal scrolling that body text gets; it was drawn as a single NoWrap line Wrap onto the following line, filling the background first so the text underneath does not show through
3 At the end of a long line there is no room to draw the composition at all The horizontal scroll extent only carried the existing 3 px safety margin Add room for about 5 full-width characters, both to the scroll extent and to the rectangle BringCaretToView asks for
4 After committing a long conversion the caret is left off screen PerformTextInput calls BringCaretToView before the scroll extent has been recomputed, so it clips against the old maximum Bring the caret into view again after the layout pass
5 The composition sits a few pixels above the committed text The composition was drawn from the top of the line, while body text is drawn from the baseline inside the line; the two differ as soon as the line picks up a fallback typeface Draw the composition from the baseline of the caret line

Measured for 4: committing 24 characters moved the horizontal offset only 384.4 → 386.9 px while the caret sat at x = 1204.2 in a viewport 981.3 px wide; the extent after the commit had grown to 1670.6. Measured for 5: 3 px.

Still open

Per-clause underlines. Japanese IMEs distinguish the clause currently being converted from the rest, but SetPreeditText(string text, int? cursorOffset) carries the text and a single cursor offset and nothing else, so the whole composition is drawn with one underline. That looks like it needs something below this component rather than in it.

Code

Everything above is in a small demo application together with the patched fork, published so that it can be reproduced:

https://github.com/YamashitaY-TextSS/avaloniaedit-ime-test

The places I changed on top of this PR are marked TEXTSS-ADD, so the diff against your work is easy to see. The demo can also render the composition without typing (--diag-preedit), which is how the numbers above were measured.

I also wrote the whole investigation up in English, as a chapter of my porting diary:

https://textss.sakura.ne.jp/en/devlog11-avaloniaedit.html

If this PR is merged, I would be glad to submit the five additions above as a follow-up PR. They sit on top of this one, so that order is the one that makes sense.

One more note: I ran into the problem your #591 fixes as well — the built-in SearchPanel's search box is a visual descendant of the TextArea, so it has its own input method client taken over, and the composition is drawn in the editor while the committed text goes to the search box. My demo sidesteps it by hosting the search panel in the Window's OverlayLayer, but #591 is the proper fix. Both of your pull requests would help people who are stuck on this.

Thank you again. Without this pull request my application would still have that hole in it.

@Timskt

Timskt commented Aug 19, 2026

Copy link
Copy Markdown
Author

Hi @Timsktyou found the way through first, and I was able to fix my own application by following your work. Thank you.

I would like to report that it also solves the problem for Japanese IME, with numbers, in case that is useful for review.

I maintain TextSS, a Japanese freeware bulk text-replacement tool that I have been releasing for 28 years. I recently ported it from VB.NET / .NET Framework to C# / .NET 10 + Avalonia, and AvaloniaEdit's missing preedit was the one blocker I could not solve: with Japanese input you cannot see what you are typing until you commit it, which is fatal for a Japanese tool. I released 6.00 with a workaround — a second, hand-made text box sitting beside the editor — rather than a fix.

Then I found this PR. It is written for Chinese IME, but the implementation is language-independent and worked for Japanese exactly as it is.

Environment

  • .NET 10, Avalonia 12.1.1, AvaloniaEdit master be976ea plus the preedit part of this PR
  • Windows 11 — Microsoft IME
  • macOS 26 (Apple Silicon) — Japanese - Romaji, both with and without Live Conversion
  • Linux Mint 22.3 Xfce — fcitx5 5.1.7 + fcitx5-mozc 2.28.4715.102

What works with this PR as it is — on all three platforms

  1. The composition is drawn inline at the caret, underlined, with a cursor inside it.
  2. The IME candidate window follows directly below the composition.
  3. The composition is not inserted into the document; committing inserts it once.
  4. Escape cancels the composition and it disappears.
  5. The composition is cleared on focus loss and when the document is replaced.
  6. The composition follows the caret when the caret moves.
  7. Multi-clause conversion, mid-line conversion, after horizontal scrolling, different font sizes, light and dark themes, word wrap on/off, line numbers on/off — all render in the right place.

Point 3 I checked numerically rather than by eye: while composing 9 characters at the end of a 58-character line, the status readout stayed at Ln 2, Col 59. Had the composition been inserted into the document it would have read Col 68.

Five things I had to add on top — none of them defects in this PR

Three of them are behaviour outside this PR's scope, and two only show up with the long, whole-sentence conversions that Japanese input produces. I list them because anyone adopting this PR for Japanese will meet them.

What happens Why What I did

1 Clicking elsewhere while composing makes the uncommitted text follow the caret #534 — Avalonia only resets the IME on ResetRequested and on a client change, never on a caret move, so the composition stays alive and only its rendering moves Commit at the old caret position during the tunnel phase of the click, before the caret moves
2 Composition text past the right edge is clipped and cannot be read The composition is never inserted into the document, so it gets none of the wrapping or horizontal scrolling that body text gets; it was drawn as a single NoWrap line Wrap onto the following line, filling the background first so the text underneath does not show through
3 At the end of a long line there is no room to draw the composition at all The horizontal scroll extent only carried the existing 3 px safety margin Add room for about 5 full-width characters, both to the scroll extent and to the rectangle BringCaretToView asks for
4 After committing a long conversion the caret is left off screen PerformTextInput calls BringCaretToView before the scroll extent has been recomputed, so it clips against the old maximum Bring the caret into view again after the layout pass
5 The composition sits a few pixels above the committed text The composition was drawn from the top of the line, while body text is drawn from the baseline inside the line; the two differ as soon as the line picks up a fallback typeface Draw the composition from the baseline of the caret line
Measured for 4: committing 24 characters moved the horizontal offset only 384.4 → 386.9 px while the caret sat at x = 1204.2 in a viewport 981.3 px wide; the extent after the commit had grown to 1670.6. Measured for 5: 3 px.

Still open

Per-clause underlines. Japanese IMEs distinguish the clause currently being converted from the rest, but SetPreeditText(string text, int? cursorOffset) carries the text and a single cursor offset and nothing else, so the whole composition is drawn with one underline. That looks like it needs something below this component rather than in it.

Code

Everything above is in a small demo application together with the patched fork, published so that it can be reproduced:

https://github.com/YamashitaY-TextSS/avaloniaedit-ime-test

The places I changed on top of this PR are marked TEXTSS-ADD, so the diff against your work is easy to see. The demo can also render the composition without typing (--diag-preedit), which is how the numbers above were measured.

I also wrote the whole investigation up in English, as a chapter of my porting diary:

https://textss.sakura.ne.jp/en/devlog11-avaloniaedit.html

If this PR is merged, I would be glad to submit the five additions above as a follow-up PR. They sit on top of this one, so that order is the one that makes sense.

One more note: I ran into the problem your #591 fixes as well — the built-in SearchPanel's search box is a visual descendant of the TextArea, so it has its own input method client taken over, and the composition is drawn in the editor while the committed text goes to the search box. My demo sidesteps it by hosting the search panel in the Window's OverlayLayer, but #591 is the proper fix. Both of your pull requests would help people who are stuck on this.

Thank you again. Without this pull request my application would still have that hole in it.

I am very glad that my PR can help everyone. Regarding the other issues beyond PR that you mentioned, I have already resolved them based on the latest fork of the Avalonianedit repository in my personal latest repository and applied it to my actual IM project. This is a software similar to WeChat chat, and its stability has been verified by at least 100+users. You can try my warehouse: https://github.com/Timskt/AvaloniaEdit.RichTextInput See if it can solve your other problems. Its usage is consistent with the official.

@YamashitaY-TextSS

Copy link
Copy Markdown

Thank you for the quick reply, and for pointing me at your repository.

AvaloniaEdit.RichTextInput turns out to be a much larger piece of work than I expected — inline images, emoji, mentions and custom components, all living inside AvaloniaEdit's own layout, selection and clipboard paths. Knowing that it is running in a real chat product with real users tells me more than any test I could write myself.

I would very much like to try it. When I have some time I will build a small demo against it, the same way I built one for this PR, and see how it behaves with Japanese IME — in particular the five points I listed. I will report back here with what I find.

Thank you again for coming back to this so quickly.

@YamashitaY-TextSS

Copy link
Copy Markdown

Hi @Timskt — thank you again for pointing me to AvaloniaEdit.RichTextInput. As promised, I built a small test app against your ava12 branch (f586eb7) and tried it with Japanese IME. First what worked, then a few suggestions — please take them purely as ideas from a different use case (a plain text/code editor, word wrap off by default, Japanese input). They are not defect reports, and the call is of course entirely yours.

What worked straight away

  • The inline composition sits exactly where the committed text ends up. I measured it pixel by pixel rather than by eye (the same string captured while composing and again after committing, compared in the same x range): 0 px difference, vertically and horizontally, both on an ASCII-only line and on an empty line, at 150 % scaling with BIZ UDGothic 14. So my point 5 ("composition a few pixels above the text") simply does not arise in Inline mode.
  • After committing a long conversion at the end of a long line, the caret stayed inside the viewport (my point 4).
  • With WordWrap = true, a composition that runs past the right edge wraps onto the next visual line exactly like body text (my point 2).
  • The things around it that I had checked before — line-break markers, line numbers, current-line highlight, my column ruler — behave the same as on my fork.

Suggestions, from the Japanese-input angle

  1. Composition at the end of a long line with WordWrap = false. With the caret at the end of a line that already fills the viewport, the composition is laid out correctly — the horizontal extent grows by its width, +336 px for 24 full-width characters in my test — but the view does not scroll, so the text being composed is to the right of the viewport and cannot be seen until it is committed. For Japanese, converting at the end of a line is the most common operation, so it would be a real improvement if the text being composed were kept visible while it is active — for example by scrolling horizontally once the preedit has been laid out, or by leaving some room at the end of the line and wrapping the overflow onto the next visual line (the latter two are what my fork does). With word wrap on it is already perfect.

  2. Room after the last character. TextView.AdditionalHorizontalScrollAmount is a private const of 3 px, which leaves about 10 px to the right of the longest line. For CJK composition a little more room is comfortable (I use 10 × WideSpaceWidth, about five full-width characters). Exposing it as a public property, or as an option on TextEditorOptions, would let applications choose without forking — and would make (1) less visible as well.

  3. Clicking elsewhere while composing (the Composition should be committed #534 case). In my environment, when I clicked elsewhere in the text area while a conversion was open, the composition stayed drawn at its original position (the candidate window stayed open too), and on confirming, the text was inserted at the new caret position — so the place where it was shown and the place where it landed were different. As far as I can see, Caret_PositionChanged does call RefreshPreeditDisplay, but PreeditTextElementGenerator.SetPreedit returns early when the text and cursor are unchanged, so nothing is redrawn, while the IME's commit goes to the current caret. Two ideas, either of which would help: (a) commit the composition at the old caret position in the tunnelling PointerPressed handler, before the caret moves — that is what I do; or (b) at least redraw the old and the new line when the caret moves, so that the display follows the caret.

  4. Overlay mode only. Two small things in ImePreeditDisplayMode.Overlay. The composition is drawn from caretRect.Right (PreeditLayer.cs, line 61), so it starts half a caret width to the right of where the committed text will be — about 1 px at 150 % scaling (0.86 px measured); drawing from caretRect.X makes the two coincide. And in the middle of a line the overlay is painted over the following characters without clearing the background, so the text underneath shows through the composition until it is committed; filling the background first (what I do in my fork) avoids that. Inline mode is already exact and pushes the following text aside, so both only matter to people who choose Overlay.

How I measured — Windows 11 at 150 % scaling, .NET 10, Avalonia 12.1.1, your ava12 branch at f586eb7 referenced as a project, Microsoft IME, BIZ UDGothic 14 pt. The test app is the one I used for this PR, with the same sample text and the same instrumentation (it can set a pseudo-composition without typing and save PNGs), pointed at your repository instead of my fork — so the numbers are directly comparable with what I reported above.

And one thought, if I may. Since you have already solved these behaviours on top of the latest AvaloniaEdit, would you consider sending them upstream as well — into this PR or as a follow-up? Many people need only a code editor rather than rich IM input, and Chinese, Japanese and Korean users all run into the same wall; your pull requests are already the closest thing to a fix that exists.

Thank you again for the work, and for sharing it.

ajkfds added a commit to ajkfds/AvaloniaEdit that referenced this pull request Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants