mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 4.8
parent
6e4fb6e4e7
commit
17952be94a
@@ -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() {
|
||||
|
||||
@@ -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![]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user