OS別にまとまった情報があまり見つからなかったため調べたこと・試したことを備忘録としてメモ.ここでいうIME変更とは,入力メソッドや入力ソース(キーボードレイアウト)の種類の変更のこととしますが,windowsではIMEオンオフ(直接入力モードかどうか)の変更も取得できます.以下の方法ではWindowsはGUI(タスクバーのインジケーターのクリック)による変更が検知できないなどの問題があるため,解決法がある場合はコメントお願いします.
利用する言語にはRustを用いていますが,dbusとOSAPIが利用できれば何でも大丈夫なはずです.コード全体はこちら.
linux
各入力メソッドフレームワークの提供するdbusを利用します.
ibus
ibusは入力エンジンの変更とその値を通知するdbusシグナル(GlobalEngineChanged)を公開しているため,それを利用します.注意点としてセッションバスではなくibusのプライベートバスを利用する必要があり,その際プライベートバスのアドレスはibus addressコマンドで取得できます.またibusはfcitx5と異なり,全角・半角キーに直接入力モードとの切り替えを割り当てています(windowsに似た設定).取得できるのはあくまでも入力エンジンの種類であり内部の入力モードを知ることはできないため,fcitx5と同じ挙動とするためには全角・半角キーの代わりにSuper(win)+spaceキーを利用することになります.
use dbus::{blocking::Connection, channel::Channel, message::MatchRule};
fn main() -> Result<(), Box<dyn std::error::Error>> {
use std::process::Command;
let cmd_out = Command::new("ibus").arg("address").output()?;
let address = String::from_utf8(cmd_out.stdout)?.trim_end().to_string();
let conn: Connection = Channel::open_private(&address)?.into();
let proxy = conn.with_proxy(
"org.freedesktop.IBus",
"/org/freedesktop/IBus",
std::time::Duration::from_millis(500),
);
let signal_mr = MatchRule::new_signal("org.freedesktop.IBus", "GlobalEngineChanged");
let _token = proxy.match_start(
signal_mr,
true,
Box::new(|message, _| {
let engine_name: String = message.read1().unwrap();
println!("{engine_name}");
true
}),
);
loop {
conn.process(std::time::Duration::from_millis(1000))?;
}
}
fcitx5
fcitx5にはdbusインターフェースに入力メソッドの変更を通知するシグナルがありません(グローバルに管理しない設計).しかし現在のアクティブな入力メソッドを取得するdbusメソッド(CurrentInputMethod)を提供しているため,ポーリングするか他の方法で変更を取得します.多くのデスクトップ環境では入力メソッドなどの変更に応じてタスクバーのアイコンを変更する方法としてdbusのStatusNotifierWatcherを実装しているため(kdeとありますが,gnomeでもデフォルトで利用可能です),それに登録されるStatusNotifierItem(fcitx5のもの)のシグナル(NewIcon)を利用します.注意点としてはStatusNotifierWatcherのRegisteredStatusNotifierItemsは行き先@オブジェクトパスの配列として返るため,適切に分割します.
use dbus::blocking::{SyncConnection, stdintf::org_freedesktop_dbus::Properties};
use dbus::message::MatchRule;
use std::sync::mpsc::sync_channel;
use std::time::Duration;
fn main() -> Result<(), dbus::Error> {
let conn = SyncConnection::new_session()?;
let notifier_watcher_proxy = conn.with_proxy(
"org.kde.StatusNotifierWatcher",
"/StatusNotifierWatcher",
Duration::from_millis(500),
);
let notifier_items: Vec<String> = notifier_watcher_proxy.get(
"org.kde.StatusNotifierWatcher",
"RegisteredStatusNotifierItems",
)?;
let fcitx5_sni_proxy = {
let mut fcitx5_sni_proxy = None;
for sni_name in notifier_items.into_iter() {
let (dest, path) = {
let (dest, path) = sni_name.split_once("@").unwrap();
(dest.to_owned(), path.to_owned())
};
let sni_proxy = conn.with_proxy(dest, path, Duration::from_millis(500));
let sni_id: String = sni_proxy.get("org.kde.StatusNotifierItem", "Id")?;
if sni_id.as_str() == "Fcitx" {
fcitx5_sni_proxy = Some(sni_proxy);
}
}
fcitx5_sni_proxy
};
let Some(fcitx5_sni_proxy) = fcitx5_sni_proxy else {
return Ok(()); // 実際にはエラーとする
};
// タスクトレイアイコン変更のタイミングを通知する
let signal_mr = MatchRule::new_signal("org.kde.StatusNotifierItem", "NewIcon");
// タイミングの通知用
struct GetInputMethod;
let (sender, receiver) = sync_channel(1);
let _token = fcitx5_sni_proxy.match_start(
signal_mr,
true,
Box::new(move |_message, _| {
let _ = sender.try_send(GetInputMethod);
true
}),
)?;
std::thread::spawn({
let worker_conn = SyncConnection::new_session()?;
move || -> Result<(), dbus::Error> {
let controller_proxy = worker_conn.with_proxy(
"org.fcitx.Fcitx5",
"/controller",
Duration::from_millis(500),
);
while let Ok(_msg) = receiver.recv() {
let (ime_status,): (String,) = controller_proxy.method_call(
"org.fcitx.Fcitx.Controller1",
"CurrentInputMethod",
(),
)?;
println!("ime_status: {ime_status}");
}
Ok(())
}
});
loop {
conn.process(Duration::from_millis(1000))?;
}
}
実際にはStatusNotifierWatcherの寿命に依存することに注意してください.また,NewIconは入力モードの切り替えでも呼ばれることに注意してください.
windows
windowsではTSFを利用して自アプリの入力メソッドの変更を通知する方法(ITfInputProcessorProfileActivationSinkなど)はありますが,「アプリウィンドウごとに異なる入力方式を設定する」にチェックを入れている場合は外部アプリへ通知は送られません.そのため愚直にフォーカス変更イベントと特定のキー入力のイベントで通知を送り,そのたびにSendMessage等を通して現在の状態を取得するようにします.
IMEオンオフの変更の取得
日本語・韓国語ユーザーの多くは半角・全角キーによるIMEオンオフを用います.前面アプリのさらにフォーカスされたウィンドウに対してWM_IME_CONTROLをつけて5をSendMessageすることでIMEオンオフを取得できます.そしてEVENT_SYSTEM_FOREGROUNDでフォーカス変更イベントを検知し,RawInputで他アプリの一部のキー入力を検知します.注意点としては各コールバック関数内ではブロッキングするsendは使わずブロッキングしないtry_sendを利用します.
use std::sync::mpsc::{SyncSender, sync_channel};
use windows::Win32::{
Devices::HumanInterfaceDevice::{HID_USAGE_GENERIC_KEYBOARD, HID_USAGE_PAGE_GENERIC},
Foundation::*,
System::LibraryLoader::GetModuleHandleW,
UI::{
Accessibility::*,
Input::{Ime::ImmGetDefaultIMEWnd, *},
WindowsAndMessaging::*,
},
};
use windows::core::{Error as WinError, w};
use once_cell::sync::OnceCell;
static GET_OPEN_STATUS_SENDER: OnceCell<SyncSender<GetOpenStatusNotification>> = OnceCell::new();
const IMC_GETOPENSTATUS: usize = 0x0005;
const VK_JP_IME_ON: u16 = 244; // VK_OEM_ENLW
const VK_JP_IME_OFF: u16 = 243; // VK_OEM_AUTO
const VK_JP_EISU: u16 = 240; // VK_OEM_ATTN
/// タイミングの通知用
struct GetOpenStatusNotification;
/// フォーカス変更時に実行されるコールバック
extern "system" fn win_event_proc(
_hwineventhook: HWINEVENTHOOK,
event: u32,
_hwnd: HWND,
_idobject: i32,
_idchild: i32,
_ideventthread: u32,
_dwmseventtime: u32,
) {
if event == EVENT_SYSTEM_FOREGROUND {
//
if let Some(sender) = GET_OPEN_STATUS_SENDER.get() {
let _ = sender.try_send(GetOpenStatusNotification);
}
}
}
#[derive(Debug)]
struct GetOpenStatusError;
impl std::fmt::Display for GetOpenStatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "GetOpenStatusError")
}
}
impl std::error::Error for GetOpenStatusError {}
// SendMessageを行うため、UIスレッド、フックなどとは異なるスレッドから呼ぶ。
fn get_open_status() -> Result<String, GetOpenStatusError> {
unsafe {
let foreground_hwnd = GetForegroundWindow();
if foreground_hwnd.is_invalid() {
return Err(GetOpenStatusError);
}
let thread_id = GetWindowThreadProcessId(foreground_hwnd, None);
// 前面ウィンドウのGUIスレッド情報
let mut gui_info = GUITHREADINFO {
cbSize: std::mem::size_of::<GUITHREADINFO>() as u32,
..Default::default()
};
let target_hwnd = if GetGUIThreadInfo(thread_id, &mut gui_info).is_ok()
&& !gui_info.hwndFocus.is_invalid()
{
gui_info.hwndFocus
} else {
foreground_hwnd
};
// IME管理ウィンドウの取得
let target_hwnd_ime = ImmGetDefaultIMEWnd(target_hwnd);
if target_hwnd_ime.is_invalid() {
return Err(GetOpenStatusError);
}
let result = {
let mut result: usize = 0;
if SendMessageTimeoutW(
target_hwnd_ime,
WM_IME_CONTROL,
WPARAM(IMC_GETOPENSTATUS),
LPARAM(0),
SMTO_NORMAL | SMTO_ABORTIFHUNG,
100,
Some(&mut result),
)
.0 == 0
{
return Err(GetOpenStatusError);
}
result
};
if result == 0 {
Ok("ime-off".to_string())
} else {
Ok("ime-on".to_string())
}
}
}
// windowprocコールバック
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
unsafe {
match msg {
WM_INPUT => {
let mut raw_input_size = 0_u32;
GetRawInputData(
HRAWINPUT(lparam.0 as _),
RID_INPUT,
None,
&mut raw_input_size,
std::mem::size_of::<RAWINPUTHEADER>() as u32,
); // サイズを取得する
let mut buf = vec![0_u8; raw_input_size as usize];
if GetRawInputData(
HRAWINPUT(lparam.0 as _),
RID_INPUT,
Some(buf.as_mut_ptr() as _),
&mut raw_input_size,
std::mem::size_of::<RAWINPUTHEADER>() as u32,
) == raw_input_size
{
let raw_input = &*(buf.as_ptr() as *const RAWINPUT);
if raw_input.header.dwType == RIM_TYPEKEYBOARD.0 {
let keyboard = raw_input.data.keyboard;
if keyboard.Message == WM_KEYDOWN {
// 特定の環境ではトグルとはならないためその都度取得する
if let VK_JP_IME_ON | VK_JP_IME_OFF | VK_JP_EISU = keyboard.VKey {
//
if let Some(sender) = GET_OPEN_STATUS_SENDER.get() {
let _ = sender.try_send(GetOpenStatusNotification);
}
}
}
}
}
LRESULT(0)
}
WM_DESTROY => {
PostQuitMessage(0);
LRESULT(0)
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
}
// windowsのuiループ
pub fn ui_loop() -> Result<(), WinError> {
unsafe {
// win_event_hook
let hook = SetWinEventHook(
EVENT_SYSTEM_FOREGROUND,
EVENT_SYSTEM_FOREGROUND,
None,
Some(win_event_proc),
0,
0,
WINEVENT_OUTOFCONTEXT,
);
// window
let hinstance = GetModuleHandleW(None)?;
let class_name = w!("ImeObserverWindow");
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
lpfnWndProc: Some(wndproc),
hInstance: hinstance.into(),
lpszClassName: class_name,
..Default::default()
};
let atom = RegisterClassExW(&wc);
debug_assert!(atom != 0);
let hwnd = CreateWindowExW(
WINDOW_EX_STYLE::default(),
class_name,
w!("instance"),
WINDOW_STYLE::default(),
0,
0,
0,
0,
Some(HWND_MESSAGE),
None,
Some(hinstance.into()),
None,
)?;
// rawinput
let rid = RAWINPUTDEVICE {
usUsagePage: HID_USAGE_PAGE_GENERIC,
usUsage: HID_USAGE_GENERIC_KEYBOARD,
dwFlags: RIDEV_INPUTSINK,
hwndTarget: hwnd,
};
RegisterRawInputDevices(&[rid], std::mem::size_of::<RAWINPUTDEVICE>() as u32)?;
let _ = ShowWindow(hwnd, SW_HIDE);
// メッセージループ
let mut msg = MSG::default();
while GetMessageW(&mut msg, None, 0, 0).into() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if !hook.is_invalid() {
let _ = UnhookWinEvent(hook);
}
}
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let (sender, receiver) = sync_channel(1);
GET_OPEN_STATUS_SENDER.set(sender).unwrap();
std::thread::spawn(move || -> Result<(), GetOpenStatusError> {
while let Ok(_msg) = receiver.recv() {
std::thread::sleep(std::time::Duration::from_millis(50)); // 反映などのため
let open_status = get_open_status()?;
println!("ime_open_status: {open_status}");
}
Ok(())
});
ui_loop()?;
Ok(())
}
キー入力検知はllhookでもできますが,セキュリティなどの面からRawInputの方がいいと思います.
キーボードレイアウト変更の取得
言語パックを複数インストールした場合,win+spaceキーあるいはalt+shiftキーで言語を入れ替えることができます.これは前面アプリのフォーカスウィンドウに対してGetKeyboardLayoutを呼ぶことで取得できるHKLから分かります.今回は簡単のためHKLの下位16ビットから取得できる言語IDをロケール文字列に変換して確認します.残りの部分は先程の例とほとんど同じため省略しています.
// lang_idをlocaleに変更する。initialize_locale_map内でのみ利用する。
fn lang_id2locale(lang_id: u16) -> Option<String> {
// MAKELCID(lang_id, SORT_DEFAULT) 相当
let locale_id = lang_id as u32;
let mut utf_16_buf = [0_u16; LOCALE_NAME_MAX_LENGTH as usize]; // 暫定的に最大まで作成する
let written_len = unsafe { LCIDToLocaleName(locale_id, Some(&mut utf_16_buf), 0) };
if written_len != 0 {
Some(String::from_utf16_lossy(
&utf_16_buf[..written_len as usize - 1],
)) // written_lenから終端文字列を引いたもの
} else {
None
}
}
/// lang_id -> locale のマップを作成する
fn initialize_locale_map() -> Result<HashMap<u16, String>, GetKeyboardLayoutError> {
unsafe {
// 言語IDのリストを取得する
let size = GetKeyboardLayoutList(None);
let mut hkl_list = vec![HKL(std::ptr::null_mut()); size as usize];
GetKeyboardLayoutList(Some(&mut hkl_list));
let lang_id_list: Vec<u16> = hkl_list
.into_iter()
.map(|hkl| (hkl.0 as usize & 0xFFFF) as u16)
.collect();
let mut locale_map: HashMap<u16, String> = HashMap::new();
for lang_id in lang_id_list.iter() {
if let Some(locale) = lang_id2locale(*lang_id) {
locale_map.insert(*lang_id, locale);
} else {
return Err(GetKeyboardLayoutError);
}
}
Ok(locale_map)
}
}
/// ループの中で呼ぶ。
fn get_keyboard_layout(
locale_map: &HashMap<u16, String>,
) -> Result<String, GetKeyboardLayoutError> {
unsafe {
let foreground_hwnd = GetForegroundWindow();
if foreground_hwnd.is_invalid() {
return Err(GetKeyboardLayoutError);
}
let thread_id = GetWindowThreadProcessId(foreground_hwnd, None);
// 前面ウィンドウのGUIスレッド情報
let mut gui_info = GUITHREADINFO {
cbSize: std::mem::size_of::<GUITHREADINFO>() as u32,
..Default::default()
};
let target_thread_id = if GetGUIThreadInfo(thread_id, &mut gui_info).is_ok()
&& !gui_info.hwndFocus.is_invalid()
{
GetWindowThreadProcessId(gui_info.hwndFocus, None)
} else {
thread_id
};
let hkl = GetKeyboardLayout(target_thread_id);
if hkl.0 as usize == 0 {
// コンソールアプリなどで起こる。
return Err(GetKeyboardLayoutError);
}
match locale_map.get(&((hkl.0 as usize & 0xFFFF) as u16)) {
Some(locale) => Ok(locale.to_owned()),
None => Err(GetKeyboardLayoutError),
}
}
}
この方法では同一言語に複数のIMEが入っていた場合(マイクロソフトIMEとGoogle日本語入力,ATOKなど)に区別できません.その場合はITfInputProcessorProfiles::EnumLanguageProfilesかITfInputProcessorProfileMgr::EnumProfilesなどを使ってあらかじめHKLと各IMEの対応関係をとっておくのが良いと思います.
macos
Carbonフレームワークでは現在の入力ソースを取得するAPI(TISCopyCurrentKeyboardInputSource)を提供しており,NotificationCenterでは入力ソースの変更を通知する専用のイベント(kTISNotifySelectedKeyboardInputSourceChanged)があるため,それを利用します.注意点としては,kTISNotifySelectedKeyboardInputSourceChangedにはDistributedNotificationCenterを使うこと(Carbonフレームワークのヘッダードキュメントにあります),CFRunLoopは必ずメインスレッドで行うことがあります.そうでないと適切に通知されません.また,TISCopyCurrentKeyboardInputSourceについてはメモリリークを防ぐためにCoreFoundationのgetルールとcreateルールに従ってください.rustではCFType::wrap_under_get_rule,CFType::wrap_under_create_ruleで非明示的に従うことができます.
use std::ffi::c_void;
use std::sync::mpsc::SyncSender;
use std::time::Duration;
use core_foundation::{
base::{CFRelease, CFType, CFTypeRef, TCFType},
dictionary::CFDictionaryRef,
runloop::{CFRunLoop, CFRunLoopRunResult, kCFRunLoopDefaultMode},
string::{CFString, CFStringRef},
};
use core_foundation_sys::notification_center::{
CFNotificationCenterAddObserver, CFNotificationCenterGetDistributedCenter,
CFNotificationCenterRef, CFNotificationCenterRemoveObserver, CFNotificationName,
CFNotificationSuspensionBehavior,
};
use once_cell::sync::OnceCell;
static GET_IME_MESSAGE_SENDER: OnceCell<SyncSender<GetKeyboardInputSourceNotification>> =
OnceCell::new();
// タイミングの通知用
struct GetKeyboardInputSourceNotification;
type TISInputSourceRef = *const c_void;
#[allow(non_upper_case_globals)]
const CFNotificationSuspensionBehaviorDeliverImmediately: CFNotificationSuspensionBehavior = 4;
#[link(name = "Carbon", kind = "framework")]
unsafe extern "C" {
static kTISPropertyInputSourceID: CFStringRef;
fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef;
fn TISGetInputSourceProperty(
input_source: TISInputSourceRef,
property_key: CFStringRef,
) -> CFTypeRef;
static kTISNotifySelectedKeyboardInputSourceChanged: CFStringRef;
}
extern "C" fn callback(
_center: CFNotificationCenterRef,
_observer: *mut c_void,
_name: CFNotificationName,
_object: *const c_void,
_user_info: CFDictionaryRef,
) {
if let Some(message_sender) = GET_IME_MESSAGE_SENDER.get() {
let _ = message_sender.try_send(GetKeyboardInputSourceNotification);
}
}
#[derive(Debug)]
struct MacError(String);
impl std::fmt::Display for MacError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "MacError: {}", self.0)
}
}
impl std::error::Error for MacError {}
fn get_current_input_source() -> Result<String, MacError> {
unsafe {
let source = TISCopyCurrentKeyboardInputSource();
let input_source = CFType::wrap_under_get_rule(TISGetInputSourceProperty(
source,
kTISPropertyInputSourceID,
))
.downcast_into::<CFString>()
.ok_or(MacError("Type mismatch.".to_string()))?
.to_string();
CFRelease(source); // createルールに従う
Ok(input_source)
}
}
fn run_loop() -> Result<(), MacError> {
unsafe {
let observer_ptr = Box::into_raw(Box::new(1)); // observer自体はなんでも良い
let notify_center = CFNotificationCenterGetDistributedCenter();
CFNotificationCenterAddObserver(
notify_center,
observer_ptr as _,
callback,
kTISNotifySelectedKeyboardInputSourceChanged,
std::ptr::null(),
CFNotificationSuspensionBehaviorDeliverImmediately,
);
// run_loop
while let run_result =
CFRunLoop::run_in_mode(kCFRunLoopDefaultMode, Duration::from_secs(1), true)
&& run_result != CFRunLoopRunResult::Stopped
{
// println!("{run_result:?}");
}
// 終了処理(guardを定義してやる方が望ましい)
CFNotificationCenterRemoveObserver(
notify_center,
observer_ptr as _,
kTISNotifySelectedKeyboardInputSourceChanged,
std::ptr::null(),
);
let _ = Box::from_raw(observer_ptr);
}
Ok(())
}
fn main() -> Result<(), MacError> {
use std::sync::mpsc::sync_channel;
let (message_sender, message_receiver) = sync_channel(1);
let _ = GET_IME_MESSAGE_SENDER.set(message_sender);
let mut pre_ime_status = "".to_string();
std::thread::spawn(move || {
while let Ok(_m) = message_receiver.recv() {
std::thread::sleep(Duration::from_millis(40));
if let Ok(ime_status) = get_current_input_source() {
//
if pre_ime_status != ime_status {
println!("{ime_status}");
pre_ime_status = ime_status;
}
}
}
});
// 必ずメインスレッドとする。
run_loop()?;
Ok(())
}