Fix lost diacritics with keyboard-hook IMEs (UniKey/OpenKey)

Hook IMEs recompose by injecting Backspace key events and VK_PACKET
characters, which egui surfaces as interleaved Event::Text and
Event::Key(Backspace) in one frame. type_text handled text/IME in one
pass and left Backspace to a later pass, so within a frame all text was
inserted before any delete and the diacritics were eaten (tiếng -> tieng).

Take Text, IME and plain Backspace/Delete in a single event-ordered pass,
removing handled keys so route_type_input does not repeat them. Modified
keys and composing frames are unchanged. Adds two canvas IME tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jordanlemcc-ship-it
2026-10-21 15:42:35 +07:00
committed by bflatastic
co-authored by Claude Opus 4.8
parent 6e4fb6e4e7
commit 17952be94a
2 changed files with 110 additions and 10 deletions
+62
View File
@@ -7148,6 +7148,68 @@ mod tests {
assert_eq!(plain(&app, id), "");
}
/// Vietnamese Telex via the Windows IME (TSF): the preedit grows digit by digit, then an empty
/// preedit plus a single Commit lands the amount once, not duplicated.
#[test]
fn vietnamese_telex_digits_commit_once() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.prefs.placeholder_text = false;
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
typing_frame(&mut app, &ctx, vec![]);
app.select_tool("type");
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let at = xf.to_screen(Point::new(100.0, 100.0));
let click =
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
typing_frame(&mut app, &ctx, vec![click(at, false)]);
let id = app.session.active().unwrap().selection.objects[0].0;
typing_frame(&mut app, &ctx, vec![]);
typing_frame(&mut app, &ctx, vec![preedit("3", 1)]);
typing_frame(&mut app, &ctx, vec![preedit("31", 2)]);
typing_frame(&mut app, &ctx, vec![preedit("316", 3)]);
typing_frame(&mut app, &ctx, vec![preedit("3160", 4)]);
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("3160".into()))]);
assert_eq!(plain(&app, id), "3160");
assert!(!app.session.tool_composing());
}
/// Keyboard-hook IMEs (UniKey/OpenKey) type Vietnamese by injecting VK_PACKET characters and
/// Backspaces, which egui surfaces as interleaved Event::Text and Event::Key(Backspace). When a
/// tone key fires, the Backspaces and the re-injected characters arrive in one frame; the Type
/// tool must apply them in order to build "tiếng" (captured from a real UniKey session), not
/// insert every character first and delete afterwards.
#[test]
fn hook_ime_backspace_and_reinject_builds_vietnamese() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.prefs.placeholder_text = false;
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
typing_frame(&mut app, &ctx, vec![]);
app.select_tool("type");
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let at = xf.to_screen(Point::new(100.0, 100.0));
let click =
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
typing_frame(&mut app, &ctx, vec![click(at, false)]);
let id = app.session.active().unwrap().selection.objects[0].0;
typing_frame(&mut app, &ctx, vec![]);
let txt = |s: &str| egui::Event::Text(s.into());
let bs = || egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
// When a tone key fires, the Backspaces and the re-injected VK_PACKET characters land
// together in one poll (one egui frame).
typing_frame(&mut app, &ctx, vec![txt("t")]);
typing_frame(&mut app, &ctx, vec![txt("i")]);
typing_frame(&mut app, &ctx, vec![txt("e")]);
typing_frame(&mut app, &ctx, vec![bs(), txt("ê")]); // 2nd "e" -> ê
typing_frame(&mut app, &ctx, vec![txt("n")]);
typing_frame(&mut app, &ctx, vec![txt("g")]);
typing_frame(&mut app, &ctx, vec![bs(), bs(), bs(), txt("ế"), txt("n"), txt("g")]); // "s" tone
assert_eq!(plain(&app, id), "tiếng");
}
/// Clear History waits while an IME composes, as Undo does: the marked text and the steps stay.
#[test]
fn clear_history_waits_while_the_ime_composes() {
+48 -10
View File
@@ -213,29 +213,67 @@ fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes
// on with it when the window comes back (measured with Kotoeri), as the tool does.
let shortcut_mods = ctx.input(|i| is_shortcut_chord(i.modifiers));
let events: Vec<egui::Event> =
ctx.input(|i| i.events.iter().filter(|e| matches!(e, egui::Event::Text(_) | egui::Event::Ime(_))).cloned().collect());
for e in events {
// A frame that began composing keeps its editing keys for the IME: a Backspace that empties the
// marked text must not then delete the committed character before it.
let was_composing = app.session.tool_composing();
// Text, IME events and plain Backspace/Delete are taken together, in the order they arrived, so
// a keyboard-hook IME (UniKey/OpenKey) whose tone key sends Backspace(s) and then re-injects
// characters in one frame interleaves correctly. Taken as two passes (all text, then the keys)
// the deletes land after the inserts and the diacritics are lost. Each handled event is removed
// so `route_type_input` below does not process the same Backspace/Delete again.
enum Seq {
Text(String),
Preedit(String, Option<std::ops::Range<usize>>),
Commit(String),
Erase(ToolKey, Mods),
}
let mut seq = vec![];
ctx.input_mut(|i| {
i.events.retain(|e| match e {
egui::Event::Text(t) => {
seq.push(Seq::Text(t.clone()));
false
}
egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
seq.push(Seq::Preedit(text.clone(), active_range_chars.clone()));
false
}
egui::Event::Ime(egui::ImeEvent::Commit(t)) => {
seq.push(Seq::Commit(t.clone()));
false
}
// Only unmodified Backspace/Delete interleave with injected text; word-delete and other
// modified forms stay with `route_type_input`.
egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. } if !m.command && !m.alt && !m.ctrl => {
let tk = if *key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete };
seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() }));
false
}
_ => true,
})
});
for e in seq {
// Errors are already reported by the engine's interaction (a failed preview keeps it).
let _ = match e {
// Keys the IME passed through come as text only when nothing is marked.
egui::Event::Text(t) if !app.session.tool_composing() && !shortcut_mods => app.session.tool_text(&t, view),
egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => app.session.tool_text(&t, view),
Seq::Text(_) => Ok(vec![]),
Seq::Preedit(text, active_range_chars) => {
app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty());
app.session.tool_preedit(&text, active_range_chars, view)
}
// A bare line break confirms the composition (Enter): the marked text, not a newline.
egui::Event::Ime(egui::ImeEvent::Commit(t)) if t == "\n" || t == "\r" => {
Seq::Commit(t) if t == "\n" || t == "\r" => {
keep_marked_text(app);
Ok(vec![])
}
egui::Event::Ime(egui::ImeEvent::Commit(t)) => {
Seq::Commit(t) => {
app.ime_marked = None;
app.session.tool_text(&t, view)
}
// `DeleteSurrounding` isn't sent by egui-winit (desktop); the web host has no IME
// path to the canvas yet.
_ => Ok(vec![]),
// While composing (or in a frame that began composing), editing keys belong to the IME.
Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => app.session.tool_key(tk, m, view),
Seq::Erase(..) => Ok(vec![]),
};
}
}