Skip to content

Commit 37a59de

Browse files
committed
add macos forward-to-ime-modifier-mask
- gate interpretKeyEvents: forwarding by a configurable modifier mask - handle IME direct commits without preedit (e.g. SKK kana mode) - swallow keys consumed via IMKTextInput.selectMode (SKK Ctrl+J, q)
1 parent af71580 commit 37a59de

8 files changed

Lines changed: 177 additions & 11 deletions

File tree

frontends/rioterm/src/bindings/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ pub fn default_key_bindings(config: &rio_backend::config::Config) -> Vec<KeyBind
731731
bindings.extend(platform_key_bindings(
732732
config.navigation.has_navigation_key_bindings(),
733733
config.navigation.use_split,
734-
config.keyboard,
734+
config.keyboard.clone(),
735735
));
736736

737737
// Add hint bindings

frontends/rioterm/src/router/window.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ pub fn configure_window(winit_window: &Window, config: &Config) {
187187
// OnlyRight - The right `Option` key is treated as `Alt`.
188188
// Both - Both `Option` keys are treated as `Alt`.
189189
// None - No special handling is applied for `Option` key.
190+
use rio_window::keyboard::ModifiersState;
190191
use rio_window::platform::macos::{OptionAsAlt, WindowExtMacOS};
191192

192193
match config.option_as_alt.to_lowercase().as_str() {
@@ -195,6 +196,20 @@ pub fn configure_window(winit_window: &Window, config: &Config) {
195196
"right" => winit_window.set_option_as_alt(OptionAsAlt::OnlyRight),
196197
_ => {}
197198
}
199+
200+
let mut mask = ModifiersState::empty();
201+
for name in &config.keyboard.forward_to_ime_modifier_mask {
202+
match name.to_lowercase().as_str() {
203+
"shift" => mask |= ModifiersState::SHIFT,
204+
"ctrl" | "control" => mask |= ModifiersState::CONTROL,
205+
"alt" | "option" => mask |= ModifiersState::ALT,
206+
"super" | "cmd" | "command" => mask |= ModifiersState::SUPER,
207+
other => tracing::warn!(
208+
"ignoring unknown forward-to-ime-modifier-mask value: {other:?}"
209+
),
210+
}
211+
}
212+
winit_window.set_forward_to_ime_modifier_mask(mask);
198213
}
199214

200215
let is_transparent = config.window.opacity < 1.;

frontends/rioterm/src/screen/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ impl Screen<'_> {
226226
split_active_color: config.colors.split_active,
227227
panel: config.panel,
228228
title: config.title.clone(),
229-
keyboard: config.keyboard,
229+
keyboard: config.keyboard.clone(),
230230
scrollback_history_limit: config.scrollback_history_limit,
231231
};
232232

@@ -528,7 +528,7 @@ impl Screen<'_> {
528528
.set_multiplier_and_divider(config.scroll.multiplier, config.scroll.divider);
529529

530530
// Update keyboard config in context manager
531-
self.context_manager.config.keyboard = config.keyboard;
531+
self.context_manager.config.keyboard = config.keyboard.clone();
532532

533533
// Re-evaluate the opaque flag — toggling `window.opacity` /
534534
// `window.blur` at runtime should flip the compositor mode.

rio-backend/src/config/defaults.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,16 @@ pub fn default_ime_cursor_positioning() -> bool {
148148
true
149149
}
150150

151+
#[inline]
152+
pub fn default_forward_to_ime_modifier_mask() -> Vec<String> {
153+
vec![
154+
String::from("shift"),
155+
String::from("ctrl"),
156+
String::from("alt"),
157+
String::from("super"),
158+
]
159+
}
160+
151161
pub fn default_config_file_content() -> String {
152162
String::from(
153163
"# See the full configuration reference: https://rioterm.com/docs/config\n",

rio-backend/src/config/keyboard.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
use serde::{Deserialize, Serialize};
22

3-
use super::defaults::{default_disable_ctlseqs_alt, default_ime_cursor_positioning};
3+
use super::defaults::{
4+
default_disable_ctlseqs_alt, default_forward_to_ime_modifier_mask,
5+
default_ime_cursor_positioning,
6+
};
47

5-
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy)]
8+
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
69
pub struct Keyboard {
710
// Disable ctlseqs with ALT keys
811
// For example: Terminal.app does not deal with ctlseqs with ALT keys
@@ -19,6 +22,20 @@ pub struct Keyboard {
1922
rename = "ime-cursor-positioning"
2023
)]
2124
pub ime_cursor_positioning: bool,
25+
26+
// Modifier mask deciding when a key event is forwarded to the macOS IME.
27+
// A key event is forwarded when no modifier is pressed, or when the
28+
// pressed modifiers intersect this mask. Otherwise the event is handled
29+
// directly by the application without going through the IME.
30+
//
31+
// Accepted values (case-insensitive): "shift", "ctrl", "alt", "super".
32+
// Useful for input methods like SKK that need to receive Ctrl+key
33+
// combinations directly.
34+
#[serde(
35+
default = "default_forward_to_ime_modifier_mask",
36+
rename = "forward-to-ime-modifier-mask"
37+
)]
38+
pub forward_to_ime_modifier_mask: Vec<String>,
2239
}
2340

2441
#[allow(clippy::derivable_impls)]
@@ -30,6 +47,7 @@ impl Default for Keyboard {
3047
#[cfg(not(target_os = "macos"))]
3148
disable_ctlseqs_alt: false,
3249
ime_cursor_positioning: default_ime_cursor_positioning(),
50+
forward_to_ime_modifier_mask: default_forward_to_ime_modifier_mask(),
3351
}
3452
}
3553
}

rio-window/src/platform/macos.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use std::os::raw::c_void;
1818

1919
use crate::event_loop::{ActiveEventLoop, EventLoopBuilder};
20+
use crate::keyboard::ModifiersState;
2021
use crate::monitor::MonitorHandle;
2122
use crate::window::{Window, WindowAttributes};
2223

@@ -106,6 +107,21 @@ pub trait WindowExtMacOS {
106107
/// Getter for the [`WindowExtMacOS::set_option_as_alt`].
107108
fn option_as_alt(&self) -> OptionAsAlt;
108109

110+
/// Set which modifier keys cause a key event to be forwarded to the macOS
111+
/// IME (`interpretKeyEvents:`).
112+
///
113+
/// A key event is forwarded to the IME when no modifier is pressed, OR
114+
/// when the pressed modifiers intersect this mask. Otherwise the event is
115+
/// handled directly by the application without going through the IME.
116+
///
117+
/// This is useful for input methods (e.g. SKK) that need to receive
118+
/// `Ctrl`/`Shift` combinations directly instead of having them processed
119+
/// as terminal control sequences.
120+
fn set_forward_to_ime_modifier_mask(&self, mask: ModifiersState);
121+
122+
/// Getter for the [`WindowExtMacOS::set_forward_to_ime_modifier_mask`].
123+
fn forward_to_ime_modifier_mask(&self) -> ModifiersState;
124+
109125
/// Makes the titlebar bigger, effectively adding more space around the
110126
/// window controls if the titlebar is invisible.
111127
fn set_unified_titlebar(&self, unified_titlebar: bool);
@@ -220,6 +236,18 @@ impl WindowExtMacOS for Window {
220236
self.window.maybe_wait_on_main(|w| w.option_as_alt())
221237
}
222238

239+
#[inline]
240+
fn set_forward_to_ime_modifier_mask(&self, mask: ModifiersState) {
241+
self.window
242+
.maybe_queue_on_main(move |w| w.set_forward_to_ime_modifier_mask(mask))
243+
}
244+
245+
#[inline]
246+
fn forward_to_ime_modifier_mask(&self) -> ModifiersState {
247+
self.window
248+
.maybe_wait_on_main(|w| w.forward_to_ime_modifier_mask())
249+
}
250+
223251
#[inline]
224252
fn set_unified_titlebar(&self, unified_titlebar: bool) {
225253
self.window

rio-window/src/platform_impl/macos/view.rs

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ pub struct ViewState {
138138
/// True if we're currently processing a key event
139139
in_key_event: Cell<bool>,
140140

141+
/// Characters of the currently-processed key event, as reported by
142+
/// `NSEvent`. Tuple of (characters, charactersIgnoringModifiers).
143+
/// Used by `insertText:` to distinguish IME direct commits (e.g. SKK
144+
/// hiragana mode) from raw keyboard pass-through (where the IME echoes
145+
/// the same characters).
146+
current_key_event_chars: RefCell<Option<(String, String)>>,
147+
141148
marked_text: RefCell<Retained<NSMutableAttributedString>>,
142149
accepts_first_mouse: bool,
143150

@@ -146,6 +153,11 @@ pub struct ViewState {
146153

147154
/// The state of the `Option` as `Alt`.
148155
option_as_alt: Cell<OptionAsAlt>,
156+
157+
/// Modifier mask deciding when a key event is forwarded to the IME.
158+
/// A key event is forwarded when no modifier is pressed, or when the
159+
/// pressed modifiers intersect this mask.
160+
forward_to_ime_modifier_mask: Cell<ModifiersState>,
149161
}
150162

151163
declare_class!(
@@ -421,23 +433,55 @@ declare_class!(
421433
let has_marked_text = unsafe { self.hasMarkedText() };
422434
let is_in_key_event = self.ivars().in_key_event.get();
423435

436+
// Detect IME direct commits (e.g. SKK hiragana mode) that bypass
437+
// preedit: an `insertText:` whose payload differs from the raw
438+
// NSEvent characters is something the IME synthesised, not a
439+
// pass-through of the pressed key.
440+
let is_ime_direct_commit = is_in_key_event
441+
&& self
442+
.ivars()
443+
.current_key_event_chars
444+
.borrow()
445+
.as_ref()
446+
.is_some_and(|(chars, chars_unmod)| {
447+
string != chars.as_str() && string != chars_unmod.as_str()
448+
});
449+
424450
if self.is_ime_enabled() {
425451
if has_marked_text && !is_control {
426452
// Clear preedit and commit the text (normal IME flow)
427453
self.queue_event(WindowEvent::Ime(Ime::Preedit(String::new(), None)));
428454
self.queue_event(WindowEvent::Ime(Ime::Commit(string)));
429455
self.ivars().ime_state.set(ImeState::Committed);
430-
} else if !is_control && !string.is_empty() && !is_in_key_event {
431-
// Direct input not from keyboard (e.g., emoji picker)
456+
} else if !is_control
457+
&& !string.is_empty()
458+
&& (!is_in_key_event || is_ime_direct_commit)
459+
{
460+
// Direct input not from keyboard (e.g., emoji picker) or
461+
// an IME committing without preedit (e.g., SKK).
432462
self.queue_event(WindowEvent::Ime(Ime::Commit(string)));
433463
self.ivars().ime_state.set(ImeState::Committed);
464+
} else if is_in_key_event && !is_ime_direct_commit {
465+
// The IME passed the raw key through unchanged (typical
466+
// when the active input source is ASCII-only or the IME
467+
// is in a passthrough mode, e.g. SKK ASCII/eisuu mode).
468+
// Treat this as "IME did not consume the key" so the key
469+
// event is forwarded to the application.
470+
self.ivars().forward_key_to_app.set(true);
434471
}
435-
} else if !is_control && !string.is_empty() && !is_in_key_event {
472+
} else if !is_control
473+
&& !string.is_empty()
474+
&& (!is_in_key_event || is_ime_direct_commit)
475+
{
436476
// IME is disabled but we got non-keyboard input (e.g., emoji picker)
437-
// Temporarily enable IME for this input
477+
// or an IME-style direct commit. Temporarily enable IME for
478+
// this input.
438479
self.queue_event(WindowEvent::Ime(Ime::Enabled));
439480
self.queue_event(WindowEvent::Ime(Ime::Commit(string)));
440481
self.ivars().ime_state.set(ImeState::Committed);
482+
} else if is_in_key_event && !is_ime_direct_commit {
483+
// IME disabled and the IME echoed the raw key — forward it.
484+
self.ivars().forward_key_to_app.set(true);
441485
}
442486
}
443487

@@ -490,13 +534,32 @@ declare_class!(
490534
self.ivars().forward_key_to_app.set(false);
491535
let event = replace_event(event, self.option_as_alt());
492536

537+
// Snapshot the NSEvent characters so `insertText:` can tell IME
538+
// direct commits (e.g. SKK hiragana mode) from raw key pass-through.
539+
let chars = unsafe { event.characters() }
540+
.map(|s| s.to_string())
541+
.unwrap_or_default();
542+
let chars_unmod = unsafe { event.charactersIgnoringModifiers() }
543+
.map(|s| s.to_string())
544+
.unwrap_or_default();
545+
*self.ivars().current_key_event_chars.borrow_mut() =
546+
Some((chars, chars_unmod));
547+
493548
// The `interpretKeyEvents` function might call
494549
// `setMarkedText`, `insertText`, and `doCommandBySelector`.
495550
// It's important that we call this before queuing the KeyboardInput, because
496551
// we must send the `KeyboardInput` event during IME if it triggered
497552
// `doCommandBySelector`. (doCommandBySelector means that the keyboard input
498553
// is not handled by IME and should be handled by the application)
499-
if self.ivars().ime_allowed.get() {
554+
//
555+
// The IME is bypassed when modifiers are pressed that do not intersect the
556+
// configured `forward_to_ime_modifier_mask`. Events with no modifiers are
557+
// always forwarded.
558+
let mods = event_mods(&event).state();
559+
let mask = self.ivars().forward_to_ime_modifier_mask.get();
560+
let forward_to_ime = mods.is_empty() || mods.intersects(mask);
561+
let routed_to_ime = self.ivars().ime_allowed.get() && forward_to_ime;
562+
if routed_to_ime {
500563
let events_for_nsview = NSArray::from_slice(&[&*event]);
501564
unsafe { self.interpretKeyEvents(&events_for_nsview) };
502565

@@ -520,7 +583,20 @@ declare_class!(
520583
_ => old_ime_state != self.ivars().ime_state.get(),
521584
};
522585

523-
if !had_ime_input || self.ivars().forward_key_to_app.get() {
586+
// When the event was routed through the IME, only forward it to
587+
// the application if `doCommandBySelector:` explicitly asked us to
588+
// (`forward_key_to_app`). Otherwise the IME is assumed to have
589+
// consumed the key — even when it produced no NSTextInputClient
590+
// callbacks (e.g. SKK switching input mode via
591+
// `IMKTextInput.selectMode()` on Ctrl+J or `q`). When the event
592+
// was *not* routed to the IME, fall back to the historical
593+
// behavior of forwarding it as long as no preedit/commit happened.
594+
let should_forward_key = if routed_to_ime {
595+
self.ivars().forward_key_to_app.get()
596+
} else {
597+
!had_ime_input
598+
};
599+
if should_forward_key {
524600
let key_event = create_key_event(&event, true, unsafe { event.isARepeat() }, None);
525601
self.queue_event(WindowEvent::KeyboardInput {
526602
device_id: DEVICE_ID,
@@ -531,6 +607,7 @@ declare_class!(
531607

532608
// Clear the flag after processing
533609
self.ivars().in_key_event.set(false);
610+
*self.ivars().current_key_event_chars.borrow_mut() = None;
534611
}
535612

536613
#[method(keyUp:)]
@@ -855,6 +932,8 @@ impl WinitView {
855932
accepts_first_mouse,
856933
_ns_window: WeakId::new(&window.retain()),
857934
option_as_alt: Cell::new(option_as_alt),
935+
forward_to_ime_modifier_mask: Cell::new(ModifiersState::all()),
936+
current_key_event_chars: RefCell::new(None),
858937
});
859938
let this: Retained<Self> = unsafe { msg_send_id![super(this), init] };
860939

@@ -1022,6 +1101,14 @@ impl WinitView {
10221101
self.ivars().option_as_alt.get()
10231102
}
10241103

1104+
pub(super) fn set_forward_to_ime_modifier_mask(&self, value: ModifiersState) {
1105+
self.ivars().forward_to_ime_modifier_mask.set(value)
1106+
}
1107+
1108+
pub(super) fn forward_to_ime_modifier_mask(&self) -> ModifiersState {
1109+
self.ivars().forward_to_ime_modifier_mask.get()
1110+
}
1111+
10251112
/// Update modifiers if `event` has something different
10261113
fn update_modifiers(&self, ns_event: &NSEvent, is_flags_changed_event: bool) {
10271114
use ElementState::{Pressed, Released};

rio-window/src/platform_impl/macos/window_delegate.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2214,6 +2214,14 @@ impl WindowExtMacOS for WindowDelegate {
22142214
self.view().option_as_alt()
22152215
}
22162216

2217+
fn set_forward_to_ime_modifier_mask(&self, mask: crate::keyboard::ModifiersState) {
2218+
self.view().set_forward_to_ime_modifier_mask(mask);
2219+
}
2220+
2221+
fn forward_to_ime_modifier_mask(&self) -> crate::keyboard::ModifiersState {
2222+
self.view().forward_to_ime_modifier_mask()
2223+
}
2224+
22172225
fn set_unified_titlebar(&self, unified_titlebar: bool) {
22182226
let window = self.window();
22192227
if unified_titlebar {

0 commit comments

Comments
 (0)