diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8f127071..b87ff3f60 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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() { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 52213c3e5..b26e4dc0e 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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 = - 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>), + 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![]), }; } }