collective

Browse / github.com/storytold/gridcraft / patch

fix(ui-egui): Korean typed over a cell keeps its first syllable whole

fix(ui-egui): Korean typed over a cell keeps its first syllable whole ## Problem Typing Korean into a selected cell (no F2) splits the first syllable into jamo: `안녕` comes out as `ㅏㄴ녕` or `ㅇ안녕`. Typing into a cell that is already being edited composes normally. Two things in the grid → in-cell editor handoff: 1. **The grid didn't allow the input method.** With no `PlatformOutput::ime`, winit disallows IME, so the first key goes around the input method and arrives as a bare `Text("ㅇ")`. 2. **Moving the focus cancels the composition.** `begin_edit` moves the focus from the grid to the in-cell editor. `Memory::request_focus` interrupts IME on a focus change, and

Status
open
Score
0.00
Adopters
0 other operators
Author
Fuzen2478 (imported from pull request #2735, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+182 −10 in 6 files
Review
pending
Shared
2026-10-28
Id
0bf1380515061a434e7aa334

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/grid.rs crates/ui-egui/src/grid/editor.rs crates/ui-egui/src/grid/keyboard.rs crates/ui-egui/tests/it/ime_typing.rs crates/ui-egui/tests/it/main.rs docs/localization-parity.md

diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs--- a/crates/ui-egui/src/grid.rs+++ b/crates/ui-egui/src/grid.rs@@ -183,6 +183,13 @@ pub struct GridState {     pub cells_rect: Option<Rect>,     /// Column and row of the clicked header cell, and where to open the menu.     pub filter_menu: Option<(u32, u32, Pos2)>,+    /// The grid's input method context: its generation, and the pass that last allowed it (a new+    /// generation starts when the grid takes the keyboard back).+    pub ime_generation: u64,+    pub ime_pass: Option<u64>,+    /// The keys that started an edit by typing over the cell, for the in-cell editor once it has+    /// the keyboard (see `keyboard::start_typing`).+    pub typed: Vec<egui::Event>,     pub context_menu: Option<Pos2>,     /// The frame a right-click opened `context_menu` in: that click's own press doesn't close it.     pub context_menu_frame: u64,diff --git a/crates/ui-egui/src/grid/editor.rs b/crates/ui-egui/src/grid/editor.rs--- a/crates/ui-egui/src/grid/editor.rs+++ b/crates/ui-egui/src/grid/editor.rs@@ -79,6 +79,14 @@ pub(super) fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, s     // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark     // theme, on the white paper): pick black or white against the fill, like Excel.     child.visuals_mut().text_cursor.stroke.color = ink_on(fill);+    // The keys that started the edit, once the editor has the keyboard (`keyboard::start_typing`).+    if !app.grid.typed.is_empty() && ui.ctx().memory(|m| m.has_focus(id)) {+        let typed = std::mem::take(&mut app.grid.typed);+        ui.ctx().input_mut(|i| {+            let later = std::mem::replace(&mut i.events, typed);+            i.events.extend(later);+        });+    }     crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);     ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect)); }diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs--- a/crates/ui-egui/src/grid/keyboard.rs+++ b/crates/ui-egui/src/grid/keyboard.rs@@ -32,6 +32,8 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res     ctx.memory_mut(|m| {         m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })     });+    app.grid.typed.clear();+    allow_ime(app, ctx, resp, geo, sh);     let events = ctx.input(|i| i.events.clone());     // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit.     let shortcut_pressed = events.iter().any(|e| {@@ -42,7 +44,8 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res     // Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once     // a shortcut ran, that text must not start an edit over what it just did.     let mut shortcut_ran = false;-    for ev in events {+    let mut events = events.into_iter();+    while let Some(ev) = events.next() {         match ev {             egui::Event::Copy => {                 app.copy_to_clipboard(ctx, "edit.copy");@@ -59,12 +62,11 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res             // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to             // insert text at, so a middle click on a cell is deliberately not a paste.             egui::Event::MiddleClickPaste { .. } => {}-            egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {}-            egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => {-                app.begin_edit(Some(text), false);-                if let Some(ed) = app.editor.as_mut() {-                    ed.caret = ed.text.chars().count();-                }+            egui::Event::Text(_) | egui::Event::Ime(_) if shortcut_pressed || shortcut_ran => {}+            egui::Event::Text(ref text) | egui::Event::Ime(egui::ImeEvent::Commit(ref text) | egui::ImeEvent::Preedit { ref text, .. })+                if text.chars().all(|c| !c.is_control()) && !text.is_empty() =>+            {+                start_typing(app, ctx, std::iter::once(ev).chain(events).collect());                 return;             }             egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => {@@ -153,3 +155,50 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res         }     } }++/// Lets the input method compose while the grid has the keyboard. Otherwise the first key of an+/// entry arrives as a lone jamo (or kana) that the next keys can no longer combine with, and the+/// in-cell editor, which allows the input method, only gets the keys after it. Not on a touch+/// screen, where an input method context brings up the on-screen keyboard.+fn allow_ime(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) {+    if ctx.input(|i| i.has_touch_screen()) {+        return;+    }+    let Some(active) = app.session.active().map(|d| d.selection.active) else { return };+    let cell = match sh.merge_at(active) {+        Some(m) => geo.range_rect(sh, m),+        None => geo.cell_rect(sh, active),+    };+    let caret = Rect::from_min_size(cell.min + vec2(2.0, 0.0), vec2(1.0, cell.height()));+    // A new context each time the grid takes the keyboard back, as a text field does on focus.+    let pass = ctx.cumulative_pass_nr();+    if app.grid.ime_pass.is_none_or(|last| last.saturating_add(1) < pass) {+        app.grid.ime_generation = app.grid.ime_generation.wrapping_add(1).max(1);+    }+    app.grid.ime_pass = Some(pass);+    let to_global = ctx.layer_transform_to_global(resp.layer_id).unwrap_or_default();+    let ime = egui::output::IMEOutput::new(+        resp.id,+        app.grid.ime_generation,+        egui::IMEPurpose::Normal,+        to_global * geo.mirror_rect(cell),+        to_global * geo.mirror_rect(caret),+    );+    ctx.output_mut(|o| o.ime = Some(ime));+}++/// Starts editing the cell with the keys typed over it. The in-cell editor gets those keys itself,+/// once it has the keyboard: egui interrupts the input method when the focus moves, which would+/// cancel the composition just started (the next key starts a new one, so a Korean syllable comes+/// apart into its jamo). So the keys are held back from this pass, which is discarded, and the+/// repeat pass, where the editor has the focus and the input method context is its own, replays+/// them: the interrupt was asked for the grid's context and doesn't reach the platform.+fn start_typing(app: &mut SheetApp, ctx: &egui::Context, typed: Vec<egui::Event>) {+    app.begin_edit(Some(String::new()), false);+    if app.editor.is_none() {+        return;+    }+    ctx.input_mut(|i| i.events.retain(|e| !typed.contains(e)));+    app.grid.typed = typed;+    ctx.request_discard("the in-cell editor takes the keys that started the edit");+}diff --git a/crates/ui-egui/tests/it/ime_typing.rs b/crates/ui-egui/tests/it/ime_typing.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/ime_typing.rs@@ -0,0 +1,103 @@+//! An entry typed over a selected cell through an input method keeps what it composes whole:+//! the grid lets the input method compose before an edit starts, and the composition carries+//! into the in-cell editor without being cancelled. Korean 2-Set typing lost or split its first+//! syllable. The events below have the shape macOS sends for Korean (a syllable is committed+//! together with the start of the next one); the composed text is Vietnamese Telex, which the+//! harness's bundled fonts can draw.+use egui::{Event, ImeEvent, Key};+use gridcraft_engine::Session;+use gridcraft_engine::core::CellRef;++use crate::common::{Harness, app, builder, harness_with};++fn harness() -> Harness {+    let mut session = Session::new();+    session.new_workbook();+    harness_with(builder().with_size(egui::vec2(1200.0, 800.0)), app(session), 4)+}++fn preedit(text: &str) -> Event {+    Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })+}++fn commit(text: &str) -> Event {+    Event::Ime(ImeEvent::Commit(text.into()))+}++fn a1(h: &Harness) -> Option<String> {+    let d = h.state().session.active().unwrap();+    d.wb.active().unwrap().cell(CellRef::new(0, 0)).map(|c| c.input_text())+}++fn draft(h: &Harness) -> Option<&str> {+    h.state().editor.as_ref().map(|ed| ed.text.as_str())+}++/// One key per frame; the composition in progress must survive every one of them.+fn type_keys(h: &mut Harness, keys: Vec<Vec<Event>>) {+    for events in keys {+        h.input_mut().events.extend(events);+        h.run_steps(1);+        let ime = h.output().platform_output.ime.as_ref();+        assert!(ime.is_some(), "the input method stays allowed while the entry is typed");+        assert!(!ime.is_some_and(|ime| ime.should_interrupt_composition), "the composition in progress is never cancelled");+    }+}++#[test]+fn the_selected_cell_lets_the_input_method_compose() {+    let h = harness();+    let ime = h.output().platform_output.ime.as_ref().expect("the grid allows the input method");+    assert!(ime.rect.is_positive(), "the candidate window opens at the active cell");+}++#[test]+fn a_composition_typed_over_a_cell_stays_whole() {+    let mut h = harness();+    // a a e e: â is committed when e starts the next letter.+    type_keys(&mut h, vec![vec![preedit("a")], vec![preedit("â")], vec![preedit(""), commit("â"), preedit("e")], vec![preedit("ê")]]);+    assert_eq!(draft(&h), Some("âê"), "the draft shows the letter being composed");+    // Return ends the composition (the input method keeps that key); the next one enters the cell.+    h.input_mut().events.extend([preedit(""), commit("ê")]);+    h.run_steps(2);+    h.key_press(Key::Enter);+    h.run_steps(2);+    assert_eq!(a1(&h).as_deref(), Some("âê"));+}++#[test]+fn an_input_method_that_replaces_the_letter_just_typed_finds_it() {+    let mut h = harness();+    // macOS input methods may type the first letter plainly, then replace it with the composed one.+    type_keys(+        &mut h,+        vec![vec![Event::Text("o".into())], vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), commit("ô")]],+    );+    assert_eq!(draft(&h), Some("ô"));+}++#[test]+fn typing_after_the_composition_goes_on_in_the_cell_editor() {+    let mut h = harness();+    type_keys(&mut h, vec![vec![preedit("o")], vec![preedit("ô")]]);+    // Space ends the composition; the in-cell editor then has the keyboard for the rest.+    h.input_mut().events.extend([preedit(""), commit("ô"), Event::Text(" ".into())]);+    h.run_steps(2);+    for c in ["1", "2"] {+        h.input_mut().events.push(Event::Text(c.into()));+        h.run_steps(2);+    }+    h.key_press(Key::Enter);+    h.run_steps(2);+    assert_eq!(a1(&h).as_deref(), Some("ô 12"));+}++#[test]+fn deleting_the_whole_composition_leaves_an_empty_draft() {+    let mut h = harness();+    type_keys(&mut h, vec![vec![preedit("a")]]);+    // Backspace inside the composition: the input method clears it.+    h.input_mut().events.push(preedit(""));+    h.run_steps(2);+    assert_eq!(draft(&h), Some(""));+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -49,6 +49,7 @@ mod header_autofit; mod header_column_drag; mod host_chrome; mod hyperlink_ctrl_click;+mod ime_typing; mod in_cell_editor_size; mod in_cell_editor_wrap; mod insert_copied_cells;diff --git a/docs/localization-parity.md b/docs/localization-parity.md--- a/docs/localization-parity.md+++ b/docs/localization-parity.md@@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Korean typed over a selected cell keeps its first syllable whole) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4  Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats,@@ -54,7 +54,10 @@ right-to-left sheets and full complex-script support. Part of   (no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet.   Text boxes and shapes keep their paragraphs' right-to-left direction (`a:pPr rtl`) through XLSX   save, though GridCraft still draws that text left to right. IME input goes-  through egui/winit and is unverified for Chinese, Japanese and Korean.+  through egui/winit. Korean typed over a selected cell on macOS composes from the first key+  (the grid allows the input method, and the in-cell editor takes the keys without cancelling+  the composition); this is verified only with the separate storytold/winit#15 fix applied.+  Chinese and Japanese, and other platforms, are unverified. - **Names in any script.** Defined names (Define Name, Create from Selection) are limited to   255 characters, counted as characters rather than UTF-8 bytes, so a 255-letter Arabic name is   accepted as in Excel. Sheet names already counted characters (31).@@ -81,7 +84,7 @@ separators, number/date formats, machine translation with agent review, and scri | Japanese | ja | 268 (~12%) | no | CJK system-font fallback; the theme's East Asian typeface (`<a:ea>`) survives open + save (rendering does not use it yet); IME unverified; no furigana display | no | menus only | 8–12 h | | German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | | Italian | it | 971 (~44%) | dialogs, tooltips, pivot pane | Latin: yes | no | menus, File view, dialogs, pivot pane | 3–5 h |-| Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h |+| Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME typing over a cell verified on macOS only with the storytold/winit#15 fix applied | no | menus only | 6–10 h | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h | | Turkish | tr | 382 (~17%) | no | Latin: yes | yes | menus only | 4–8 h | @@ -110,6 +113,7 @@ language (none so far) and of localized function names.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Korean typed over a selected cell no longer splits its first syllable into jamo: the grid allows the input method while it has the keyboard, and the keys that start an edit are replayed to the in-cell editor in a repeat pass, so moving the focus doesn't cancel the composition (`crates/ui-egui/tests/it/ime_typing.rs`). Checked on macOS with Apple 2-Set Korean; a winit fix for a jamo lost after a commit is a separate storytold/winit PR | | 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |