From 03d99009903fe598b6720247ae160fc8ef6d3f1a Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:16:26 +0200 Subject: [PATCH] M3: preserve IME composition in command search (#1252) (#1638) Co-authored-by: Codex --- crates/ui-egui/src/palette.rs | 8 ++-- crates/ui-egui/tests/commands.rs | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index f4d0b6179..4f4197927 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -159,15 +159,17 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { .font(theme::regular(15.0)) .desired_width(f32::INFINITY), ); - // Enter runs the top hit. The field keeps focus (requested every frame), so - // check while it is focused as well as when focus is lost. + // Enter runs the selected hit while the field is focused or relinquishing focus. if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) && let Some((_, h)) = hits.get(selected) { chosen = Some((h.command, h.group)); } - r.request_focus(); + // Re-requesting existing focus interrupts native IME composition. + if !r.has_focus() { + r.request_focus(); + } }); ui.separator(); for (index, (_, h)) in hits.iter().enumerate() { diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index a7023875e..47bbc21a8 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -506,3 +506,77 @@ fn command_w_still_closes_the_document_tab_on_macos() { h.run_steps(2); assert!(h.state().views.is_empty(), "⌘W closes the clean document tab"); } + +fn palette_ime_output_requests_interrupt(h: &Harness<'_, PdfCraftApp>, phase: &str) -> bool { + assert!(h.state().palette_open, "palette closed during {phase}"); + let ime = h.output().platform_output.ime.as_ref().expect("focused palette advertises IME"); + eprintln!("{phase}: query={:?}; should_interrupt_composition={}", h.state().palette_query, ime.should_interrupt_composition); + ime.should_interrupt_composition +} + +fn palette_ime_output_is_uninterrupted(h: &Harness<'_, PdfCraftApp>, phase: &str) { + assert!(!palette_ime_output_requests_interrupt(h, phase), "palette interrupted composition during {phase}"); +} + +#[test] +fn palette_ime_idle_frames_and_reopen_do_not_interrupt_composition() { + let mut h = harness(); + for opening in 0..2 { + h.state_mut().run_command("view.palette"); + h.run_steps(3); + for frame in 0..3 { + h.step(); + palette_ime_output_is_uninterrupted(&h, &format!("opening {opening}, idle {frame}")); + } + h.key_press(Key::Escape); + h.run_steps(2); + assert!(!h.state().palette_open); + } +} + +#[test] +fn palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted() { + let mut h = harness(); + let mut interruptions = Vec::new(); + h.state_mut().run_command("view.palette"); + h.run_steps(3); + for preedit in ["ni", "nih"] { + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: preedit.into(), active_range_chars: Some(0..preedit.chars().count()) })); + h.step(); + interruptions.push(palette_ime_output_requests_interrupt(&h, preedit)); + h.step(); + interruptions.push(palette_ime_output_requests_interrupt(&h, "preedit idle")); + } + h.event(egui::Event::Ime(egui::ImeEvent::Commit("你".into()))); + h.step(); + assert_eq!(h.state().palette_query, "你"); + interruptions.push(palette_ime_output_requests_interrupt(&h, "Chinese commit")); + h.run_steps(2); + assert_eq!(h.state().palette_query, "你"); + interruptions.push(palette_ime_output_requests_interrupt(&h, "committed idle")); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "hao".into(), active_range_chars: Some(0..3) })); + h.step(); + interruptions.push(palette_ime_output_requests_interrupt(&h, "second preedit")); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None })); + h.step(); + assert_eq!(h.state().palette_query, "你"); + interruptions.push(palette_ime_output_requests_interrupt(&h, "cancel preedit")); + assert!(interruptions.iter().all(|interrupt| !interrupt), "composition interrupted: {interruptions:?}"); +} + +#[test] +fn palette_ime_outside_click_closes_without_running_a_command() { + let mut h = harness(); + h.state_mut().set_option("palette", "rotate pages clockwise").unwrap(); + h.run_steps(3); + h.event(egui::Event::PointerButton { + pos: egui::pos2(20.0, 850.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Modifiers::NONE, + }); + h.step(); + assert!(!h.state().palette_open); + assert!(h.state().palette_query.is_empty()); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.pages[0].rotation, 0); +}