From 42242e3c8b88e556282926e09c223bc809aaa1e7 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 18 Oct 2026 01:41:15 +0700 Subject: [PATCH] M1: IME composition in text layers (#747) (#755) - Text editing on the canvas now tells the platform it wants the IME on **every** frame of the edit. Before, it only did so on frames that brought input events. - The candidate window opens at the caret. Before, it opened at the canvas corner: the desktop integration places it by `IMEOutput.rect`, which was the whole canvas. - Keys that reach the editor while a composition is in progress belong to the IME. Enter, Escape, Backspace, arrows and shortcuts don't act on the text then, so Enter picking a candidate doesn't add a newline. - New test `crates/ui-egui/src/ime_tests.rs`. - Docs: the IME rows in `docs/ui-parity.md` and `ROADMAP.md` now say it hasn't been checked on a Mac. Fixes #747. --- ROADMAP.md | 2 +- crates/ui-egui/src/ime_tests.rs | 87 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/shortcuts.rs | 15 ++++-- docs/ui-parity.md | 5 +- 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/ime_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3908e591..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,7 +117,7 @@ French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and I Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, -IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md). +IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md). ## Upcoming diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs new file mode 100644 index 00000000..a6db0cbd --- /dev/null +++ b/crates/ui-egui/src/ime_tests.rs @@ -0,0 +1,87 @@ +//! IME composition in a text layer, driven through egui headless with the events macOS sends for +//! Pinyin ("nihao", then a candidate picked): the platform keeps the IME on every frame of the +//! edit (an idle frame too) with the candidate window at the caret, the composition shows without +//! entering the document, keys pressed while composing are the IME's, and the commit lands once. + +use egui::{Event, ImeEvent, Key, Modifiers, RawInput, Rect}; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE } +} + +fn preedit(text: &str) -> Event { + let n = text.chars().count(); + Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: (n > 0).then_some(n..n) }) +} + +#[expect(deprecated, reason = "egui still delivers these, and the editor must ignore them")] +fn enabled_disabled() -> (Event, Event) { + (Event::Ime(ImeEvent::Enabled), Event::Ime(ImeEvent::Disabled)) +} + +fn text(u: &Ui, id: NodeId) -> String { + match &u.app.session.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.characters.clone(), + _ => panic!("not text"), + } +} + +/// One frame; the IME output it leaves for the platform (`None` turns the IME off). +fn ime(u: &mut Ui, events: Vec) -> Rect { + let ime = u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing"); + assert_eq!(ime.rect, ime.cursor_rect, "the desktop places candidates by `rect`: the caret"); + ime.cursor_rect +} + +fn composing(u: &Ui) -> Option { + u.app.session.ds().unwrap().text_edit.as_ref().expect("still editing").preedit.as_ref().map(|p| p.text.clone()) +} + +#[test] +fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { + // The text is measured with a font: without craft-fonts a system one, scanned here rather than + // by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = NodeId(s.execute("text.create", &json!({"text": "Hi", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + // On a machine without any font nothing lays out and every caret sits at the start. + let fonts = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).is_some_and(|(l, _)| !l.glyphs.is_empty()); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + // An idle frame (the caret blinking) keeps the IME on, or macOS drops the composition. + let caret = ime(&mut u, vec![]); + let ds = u.app.session.ds().unwrap(); + let b = ds.doc.world_bounds(t).unwrap(); + let layer = Rect::from_two_pos(u.app.to_screen(b.x0, b.y0), u.app.to_screen(b.x1, b.y1)).expand(1.0); + assert!(layer.contains(caret.center()), "candidates open at the caret ({caret:?} in {layer:?})"); + + let (enabled, disabled) = enabled_disabled(); + ime(&mut u, vec![enabled, preedit("n")]); + ime(&mut u, vec![]); + let mid = ime(&mut u, vec![preedit("ni")]); + assert!(!fonts || mid.min.x > caret.min.x, "the IME cursor follows the composition"); + // A key the platform leaks while composing (Backspace) belongs to the IME, not the text. + ime(&mut u, vec![key(Key::Backspace, true), preedit("nih"), key(Key::Backspace, false)]); + ime(&mut u, vec![preedit("nihao")]); + assert_eq!(composing(&u).as_deref(), Some("nihao")); + assert_eq!(text(&u, t), "Hi", "the composition isn't in the document yet"); + + // Enter confirms the candidate: no newline, editing goes on. + let after = ime(&mut u, vec![key(Key::Enter, true), preedit(""), Event::Ime(ImeEvent::Commit("你好".into())), disabled, key(Key::Enter, false)]); + assert_eq!(text(&u, t), "Hi你好"); + assert_eq!(composing(&u), None); + let te = u.app.session.ds().unwrap().text_edit.clone().unwrap(); + assert_eq!((te.caret, te.anchor), (4, 4)); + assert!(!fonts || after.min.x > caret.min.x, "the caret moved past the committed text"); + ime(&mut u, vec![]); + assert_eq!(text(&u, t), "Hi你好", "committed once"); + + // Keys are the editor's again once the composition is over. + ime(&mut u, vec![key(Key::Enter, true), key(Key::Enter, false), Event::Text("!".into())]); + assert_eq!(text(&u, t), "Hi你好\n!"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6a14ede..ceea7005 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,6 +117,8 @@ mod home_tests; #[cfg(test)] mod idle_tests; #[cfg(test)] +mod ime_tests; +#[cfg(test)] mod import_report_tests; #[cfg(test)] mod layers_behaviour_tests; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 09b2c57b..f68a24f3 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -102,14 +102,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe return; } let events: Vec = ctx.input(|i| i.events.clone()); - if events.is_empty() { - return; - } + // Every frame while editing, even one without input: a frame without the IME output turns the + // platform's IME off, which drops the composition (macOS then types plain letters). let editing = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false); if editing { text_keys(app, ctx, &events); return; } + if events.is_empty() { + return; + } // An image-only clipboard brings no paste event: paste from the key then. The browser sends // the press; the desktop shell swallows it and sends only the release. let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_))); @@ -448,6 +450,10 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) { } } Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { + // Keys during a composition are the IME's (Enter picks a candidate, Escape cancels). + if app.session.ds().is_ok_and(|d| d.text_edit.as_ref().is_some_and(|te| te.preedit.is_some())) { + continue; + } if (m.command || m.alt || m.ctrl) && text_shortcut(app, *key, *physical_key, *m) { skip_text = true; continue; @@ -532,7 +538,8 @@ fn ime_output(app: &FigApp, ctx: &Context) { ctx.output_mut(|o| { o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, - rect: app.canvas.rect, + // The desktop places the candidate window by `rect` (the web by `cursor_rect`). + rect: cursor_rect, cursor_rect, should_interrupt_composition: false, }) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..64bda29f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -85,7 +85,7 @@ interaction feel not yet compared. | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | -| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | +| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). |