diff --git a/crates/openpencil-shell-core/src/widgets/dropdown.rs b/crates/openpencil-shell-core/src/widgets/dropdown.rs index 699d55aa7..8f37f946b 100644 --- a/crates/openpencil-shell-core/src/widgets/dropdown.rs +++ b/crates/openpencil-shell-core/src/widgets/dropdown.rs @@ -20,12 +20,24 @@ impl DropdownState { /// stays a pure state mutator without borrowing the Dropdown /// struct around it. /// - /// Skips when the key is released or the dropdown has no options - /// (no-op rather than wrap-around / panic). Non-navigation keys - /// fall through unchanged so widget code can layer additional - /// handlers later (e.g. typeahead in Phase D+). + /// Skips when the key is released, the dropdown has no options, + /// OR an IME composition is in progress (`event.is_composing == + /// true`). The IME-skip is required by spec §2.4: while the + /// user is composing CJK / dead-key sequences, arrow keys belong + /// to the IME's candidate-picker UI, NOT to widgets that read + /// keys directly. Without this guard, every arrow keystroke + /// during a composition would cycle the dropdown selection + /// behind the IME panel — surfaced by the codex Phase C + /// stop-hook ("focused IME textarea lets composing keys mutate + /// dropdown state"). + /// + /// Non-navigation keys fall through unchanged so widget code can + /// layer additional handlers later (e.g. typeahead in Phase D+). pub fn apply_key(&mut self, event: &crate::KeyEvent, option_count: usize) { - if event.state != crate::KeyState::Pressed || option_count == 0 { + if event.state != crate::KeyState::Pressed + || option_count == 0 + || event.is_composing + { return; } match event.key { diff --git a/crates/openpencil-shell-core/tests/widgets_static.rs b/crates/openpencil-shell-core/tests/widgets_static.rs index 2db480c99..8b038fdc5 100644 --- a/crates/openpencil-shell-core/tests/widgets_static.rs +++ b/crates/openpencil-shell-core/tests/widgets_static.rs @@ -442,3 +442,36 @@ fn dropdown_apply_key_ignores_unrelated_keys() { assert_eq!(state.selected, 1); assert!(state.open); } + +#[test] +fn dropdown_apply_key_ignores_keys_during_ime_composition() { + // Spec §2.4 widget rule: skip key dispatch while is_composing is + // true. ArrowDown during a CJK composition belongs to the IME's + // candidate-picker UI, NOT to the dropdown selection cycle. + // Codex Phase C stop-hook (#2): "focused IME textarea lets + // composing keys mutate dropdown state." + let mut state = DropdownState { + selected: 0, + open: false, + }; + let composing_arrow = KeyEvent { + is_composing: true, + ..keydown(NamedKey::ArrowDown) + }; + state.apply_key(&composing_arrow, 3); + assert_eq!(state.selected, 0, "composing ArrowDown must not advance"); + assert!(!state.open, "composing ArrowDown must not open"); + + // Same protection for Enter / Escape — IME 'commit' shouldn't + // close the dropdown. + let mut state2 = DropdownState { + selected: 1, + open: true, + }; + let composing_enter = KeyEvent { + is_composing: true, + ..keydown(NamedKey::Enter) + }; + state2.apply_key(&composing_enter, 3); + assert!(state2.open, "composing Enter must not close"); +}