From b743410e726e2e99fd9f9c5d07fb729d38ca79df Mon Sep 17 00:00:00 2001 From: WaWu Date: Tue, 13 Oct 2026 14:46:10 +0800 Subject: [PATCH] UI: Quick Apply keeps an IME composition while pinyin is typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command palette's search field took the keyboard with `request_focus()` on every frame it was drawn. egui's `Memory::request_focus` interrupts the IME composition, and `egui-winit` acts on `should_interrupt_composition` with `set_ime_allowed(false)` + `(true)` — on Windows that is `ImmAssociateContextEx(hwnd, 0, IACE_CHILDREN/IACE_DEFAULT)`, which drops the composition in progress. With Microsoft Pinyin the pre-edit text was cancelled over and over, so the pinyin never accumulated and nothing could be committed: Chinese (and Japanese/Korean) could not be typed into Quick Apply at all, while Latin typing was unaffected. Ask for the focus only when the field hasn't got it, the same guard the inline editors use (`widgets::keep_focus`): the palette still takes the keyboard when it opens, and takes it back when something else takes it away. Tests: panels::dialogs::tests::the_palette_leaves_a_pinyin_composition_alone opens the palette, feeds Preedit("n".."nihao") frame by frame and asserts that the frame never asks the platform to cancel the composition, that the whole pre-edit text is in the query, and that the field claims the keyboard back when another widget takes it. Failed before: "typing `n` cancelled the composition". --- crates/ui-egui/src/panels/dialogs.rs | 58 +++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e95a667e..975e5c8d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -568,7 +568,13 @@ fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { .collect(); modal(ctx, "Command Palette", vec2(560.0, 470.0), t, |ui| { let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Type a command or effect…")).desired_width(f32::INFINITY).font(Tokens::ui(14.0))); - r.request_focus(); + // Take the keyboard when the palette opens (and if anything else takes it away), but not + // while the field already has it: egui's `request_focus` interrupts the IME composition, + // and asking on every frame cancelled the pinyin being typed, so no Chinese could be + // entered. + if !ctx.memory(|m| m.has_focus(r.id)) { + r.request_focus(); + } ui.add_space(8.0); let sel = app.dialog_state.palette_sel.min(matches.len().saturating_sub(1)); for (i, (label, id, params, sc)) in matches.iter().enumerate() { @@ -635,6 +641,56 @@ mod tests { out.textures_delta.clear(); } + /// One frame with `events` in its input; returns what it asked the platform to do. + fn frame_with(app: &mut EffectcraftApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { + let input = egui::RawInput { events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.auto.begin_frame(); + show(app, ui.ctx()); + }); + out.textures_delta.clear(); + out + } + + /// The IME composing `text` so far (what the platform sends as the user types pinyin). + fn composing(text: &str) -> egui::Event { + egui::Event::Ime(egui::ImeEvent::Preedit { text: text.to_string(), active_range_chars: None }) + } + + /// Whether the frame asked the platform to cancel the composition in progress. winit does + /// that whenever egui's focus is requested (`Memory::request_focus` interrupts the IME), and + /// on Windows the IME then drops what is being typed. + fn cancels_composition(out: &egui::FullOutput) -> bool { + out.platform_output.ime.as_ref().is_some_and(|i| i.should_interrupt_composition) + } + + /// The palette's search field used to take the keyboard on every frame it was drawn, and + /// asking egui for the focus interrupts the IME composition: the pinyin being typed was + /// cancelled as it was typed, so no Chinese could be entered. + #[test] + fn the_palette_leaves_a_pinyin_composition_alone() { + let (mut app, ctx) = app(); + crate::menus::invoke(&mut app, &ctx, "app.commandPalette", json!({})).unwrap(); + assert_eq!(app.dialog, Some(Dialog::CommandPalette)); + // The field takes the keyboard when the palette opens. + frame(&mut app, &ctx); + // Typing pinyin: the composition goes on and nothing cancels it. + let mut typed = String::new(); + for k in "nihao".chars() { + typed.push(k); + let out = frame_with(&mut app, &ctx, vec![composing(&typed)]); + assert!(out.platform_output.ime.is_some(), "the field owns the IME"); + assert!(!cancels_composition(&out), "typing `{typed}` cancelled the composition"); + } + // The whole composition is in the field (and in the query the next frame filters with). + assert_eq!(app.dialog_state.palette_query, "nihao"); + // Something else taking the keyboard takes it away for a frame; the field claims it back + // (a request is what gets the keys back, and there is no composition to interrupt then). + ctx.memory_mut(|m| m.request_focus(egui::Id::new("elsewhere"))); + assert!(frame_with(&mut app, &ctx, vec![]).platform_output.ime.is_none(), "the field lost the keyboard"); + assert!(frame_with(&mut app, &ctx, vec![]).platform_output.ime.is_some(), "and takes it back"); + } + fn solid_item(app: &EffectcraftApp, l: u64) -> ItemId { match app.session.active_comp().unwrap().layer(LayerId(l)).unwrap().source { LayerSource::Solid { item } => item,