From f32727ddca01a033a7af072ac1a5d504b9178c05 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Uma=C4=B5o?= <107099960+umajho@users.noreply.github.com>
Date: Tue, 17 Mar 2026 03:09:07 +0800
Subject: [PATCH] Improve IME, and restrict mac-specific workaround (#7973)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Closes N/A
* [x] I have followed the instructions in the PR template
My PR that fixes the macOS backspacing issue (#7810) unfortunately
breaks text selection on Wayland (Fedora KDE Plasma Desktop 43 [Wayland,
with or without IBus]). I had actually tested on a Wayland setup but
failed to notice that :(
Windows and Linux+X11 (Debian 13 [Cinnamon 6.4.10 + X11 + fcitx5 5.1.2])
are not affected.
This PR fixes the issue by restricting the macOS fix to macOS-only.
Here is the correct behavior on Wayland after this PR
(and before #7810 is applied)

Here is the buggy behavior on Wayland before this
PR

## Cause of the Wayland issue
On Wayland, `winit` constantly emits `winit::event::Ime::Preedit("",
None)` events.
PR #7810 added these lines for handling `winit::event::Ime::Preedit(_,
None)` in `egui-winit` without considering the `target_os`:
https://github.com/emilk/egui/blob/14afefa2521d1baaf4fd02105eec2d3727a7ac36/crates/egui-winit/src/lib.rs#L619-L621
As a result, while text is being selected, `egui-winit` receives these
`winit::event::Ime::Preedit("", None)` events from `winit` and forwards
them to `egui` as `egui::ImeEvent::Preedit("")`. `egui` then clears the
current text selection, because it currently does not distinguish
between IME pre-edit text and selected text.
---------
Co-authored-by: lucasmerlin
---
crates/egui-winit/src/lib.rs | 47 +++++++++------
crates/egui/src/widgets/text_edit/builder.rs | 62 ++++++++++++--------
2 files changed, 65 insertions(+), 44 deletions(-)
diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs
index 243ed119a..234a9989b 100644
--- a/crates/egui-winit/src/lib.rs
+++ b/crates/egui-winit/src/lib.rs
@@ -548,23 +548,23 @@ impl State {
///
/// | Setup | Events in Order |
/// | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
- /// | a-macos15-apple_shuangpin | `Predict("", None)` -> `Commit("测试")` |
- /// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Predict("", None)` -> `Commit("测试")` -> `Predict("", Some(0, 0))` -> `Predict("", None)` (duplicate until `TextEdit` blurred) |
- /// | c-windows11-ms_pinyin | `Predict("测试", Some(…))` -> `Predict("", None)` -> `Commit("测试")` -> `Disabled` |
+ /// | a-macos15-apple_shuangpin | `Preedit("", None)` -> `Commit("测试")` |
+ /// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Preedit("", None)` -> `Commit("测试")` -> `Preedit("", Some(0, 0))` -> `Preedit("", None)` (duplicate until `TextEdit` blurred) |
+ /// | c-windows11-ms_pinyin | `Preedit("测试", Some(…))` -> `Preedit("", None)` -> `Commit("测试")` -> `Disabled` |
///
- /// #### Situation: pressed backspace to delete the last character in the prediction
+ /// #### Situation: pressed backspace to delete the last character in the composition
///
/// | Setup | Events in Order |
- /// | a-macos15-apple_shuangpin | `Predict("", None)` |
- /// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Predict("", Some(0, 0))` -> `Predict("", None)` (duplicate until `TextEdit` blurred) |
- /// | c-windows11-ms_pinyin | `Predict("", Some(0, 0))` -> `Predict("", None)` -> `Commit("")` -> `Disabled` |
+ /// | a-macos15-apple_shuangpin | `Preedit("", None)` |
+ /// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Preedit("", Some(0, 0))` -> `Preedit("", None)` (duplicate until `TextEdit` blurred) |
+ /// | c-windows11-ms_pinyin | `Preedit("", Some(0, 0))` -> `Preedit("", None)` -> `Commit("")` -> `Disabled` |
///
- /// #### Situation: clicked somewhere else while there is an active composition with the prediction "ce"
+ /// #### Situation: clicked somewhere else while there is an active composition with the pre-edit text "ce"
///
/// | Setup | Events in Order |
/// | ------------------------------------------- | ------------------------------------------------------------------------------------------------- |
/// | a-macos15-apple_shuangpin | nothing emitted |
- /// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Predict("", Some(0, 0))` (duplicate) -> `Predict("", None)` (duplicate until `TextEdit` blurred) |
+ /// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Preedit("", Some(0, 0))` (duplicate) -> `Preedit("", None)` (duplicate until `TextEdit` blurred) |
/// | c-windows11-ms_pinyin | nothing emitted |
fn on_ime(&mut self, ime: &winit::event::Ime) {
// // code for inspecting ime events emitted by winit:
@@ -610,15 +610,26 @@ impl State {
self.ime_event_disable();
}
winit::event::Ime::Preedit(_, None) => {
- // we need to emit this on macOS, since winit doesn't emit
- // `Predict("", Some(0, 0))` before this event on macOS when the
- // user deletes the last character in the prediction with the
- // backspace key. Without this, only `egui::ImeEvent::Disabled`
- // is emitted here, leading to the last character being left in
- // TextEdit in such situation.
- self.egui_input
- .events
- .push(egui::Event::Ime(egui::ImeEvent::Preedit(String::new())));
+ if cfg!(target_os = "macos") {
+ // On macOS, when the user presses backspace to delete the
+ // last character in an IME composition, `winit` only emits
+ // `winit::event::Ime::Preedit("", None)` without a
+ // preceding `winit::event::Ime::Preedit("", Some(0, 0))`.
+ //
+ // The current implementation of `egui::TextEdit` relies on
+ // receiving an `egui::ImeEvent::Preedit("")` to remove the
+ // last character in the composition in this case, so we
+ // emit it here.
+ //
+ // This is guarded to macOS-only, as applying it on other
+ // platforms is unnecessary and can cause undesired
+ // behavior.
+ // See: https://github.com/emilk/egui/pull/7973
+ self.egui_input
+ .events
+ .push(egui::Event::Ime(egui::ImeEvent::Preedit(String::new())));
+ }
+
self.ime_event_disable();
}
}
diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs
index fbf25babf..b9fdb1cbe 100644
--- a/crates/egui/src/widgets/text_edit/builder.rs
+++ b/crates/egui/src/widgets/text_edit/builder.rs
@@ -1066,26 +1066,36 @@ fn events(
} => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key),
Event::Ime(ime_event) => {
- /// Empty prediction can be produced with [`ImeEvent::Preedit`]
- /// or [`ImeEvent::Commit`] when user press backspace or escape
- /// during IME, so this function should be called in both cases
- /// to clear current text.
+ /// Both `ImeEvent::Preedit("")` and `ImeEvent::Commit("")`
+ /// might be emitted from different integrations to signify that
+ /// the current IME composition should be cleared.
///
- /// Example platforms where only `ImeEvent::Preedit("")` of
- /// those two events is emitted when the last character in the
- /// prediction is deleted:
- /// - macOS 15.7.3.
- /// - Debian13 with gnome48 and wayland.
+ /// Example integrations where only `ImeEvent::Preedit("")` of
+ /// those two events is emitted when the last character is
+ /// deleted with a backspace:
+ /// - `egui-winit` on macOS 15.7.3.
+ /// - `egui-winit` on Debian13 with gnome48 and wayland.
///
- /// An example platform where only `ImeEvent::Commit("")` of
- /// those two events is emitted when the last character in the
- /// prediction is deleted:
- /// - Safari 26.2 (on macOS 15.7.3).
- fn clear_prediction(
+ /// An example integration where only `ImeEvent::Commit("")` of
+ /// those two events is emitted when the last character is
+ /// deleted with a backspace:
+ /// - `eframe`'s web integration on Safari 26.2 (on macOS
+ /// 15.7.3).
+ ///
+ /// ## Note
+ ///
+ /// The term “pre-edit string” is used by X11 and Wayland, and
+ /// we use “pre-edit text” and “pre-edit range” here in the
+ /// same manner.
+ /// See:
+ ///
+ /// We previously referred to “pre-edit text” as “prediction”,
+ /// which is not standard and can mean different things.
+ fn clear_preedit_text(
text: &mut dyn TextBuffer,
- cursor_range: &CCursorRange,
+ preedit_range: &CCursorRange,
) -> CCursor {
- text.delete_selected(cursor_range)
+ text.delete_selected(preedit_range)
}
match ime_event {
@@ -1094,33 +1104,33 @@ fn events(
state.ime_cursor_range = cursor_range;
None
}
- ImeEvent::Preedit(text_mark) => {
- if text_mark == "\n" || text_mark == "\r" {
+ ImeEvent::Preedit(preedit_text) => {
+ if preedit_text == "\n" || preedit_text == "\r" {
None
} else {
- let mut ccursor = clear_prediction(text, &cursor_range);
+ let mut ccursor = clear_preedit_text(text, &cursor_range);
let start_cursor = ccursor;
- if !text_mark.is_empty() {
- text.insert_text_at(&mut ccursor, text_mark, char_limit);
+ if !preedit_text.is_empty() {
+ text.insert_text_at(&mut ccursor, preedit_text, char_limit);
}
state.ime_cursor_range = cursor_range;
Some(CCursorRange::two(start_cursor, ccursor))
}
}
- ImeEvent::Commit(prediction) => {
- if prediction == "\n" || prediction == "\r" {
+ ImeEvent::Commit(commit_text) => {
+ if commit_text == "\n" || commit_text == "\r" {
None
} else {
state.ime_enabled = false;
- let mut ccursor = clear_prediction(text, &cursor_range);
+ let mut ccursor = clear_preedit_text(text, &cursor_range);
- if !prediction.is_empty()
+ if !commit_text.is_empty()
&& cursor_range.secondary.index
== state.ime_cursor_range.secondary.index
{
- text.insert_text_at(&mut ccursor, prediction, char_limit);
+ text.insert_text_at(&mut ccursor, commit_text, char_limit);
}
Some(CCursorRange::one(ccursor))