UI: Quick Apply keeps an IME composition while pinyin is typed

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".
This commit is contained in:
WaWu 2026-10-13 14:46:10 +08:00
parent 470ea7a3af
commit b743410e72
1 changed files with 57 additions and 1 deletions

View File

@ -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::Event>) -> 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,