LinuxのGLFWで日本語入力に対応した話
GLFWベースのLinux版OpenSiv3Dで日本語入力をしようとすると、IMEの入力ボックスらしきものは出るもののイベントが正しく送られない問題がありました。
この問題を修正をしたときに色々LinuxのIME周りを調べたのでまとめました。
今後GLFW,X11環境でIME対応をする方の参考になればと思います。
前例
GLFW IME
でggるとどうやら一度GLFWのリポジトリにIMEに関するIssue(#41)とPR(#658)を上げていた方々がいたようですが、どちらも進展が無いままになっていました。
このプルリクを手動でマージすることも考えましたが、今との差分が大きすぎるため、参考にしつつ1から調べて自分で実装することにしました。
調査
プルリクの調査
最初にshibukawaさんによるプルリクのソースコード(src/x11_window.c)について調べました。
IMEの入力状態を取得するために、XCreateICに渡すXNInputStyleのプロパティをXIMPreeditNothing
からXIMPreeditCallbacks
に変えているようです。
同時にXNPreeditAttributesにコールバック関数を渡すことで、入力メソッドの更新を受け取っているようでした。
XIMの検証
次にコールバックの内容や表示方法を調査するため、実際にコードを用意して検証しました。
検証に使ったコード
XNInputStyleのフラグにはそれぞれ次のような呼び名が対応しているようです。
XIMPreeditCallbacks: on-the-spot
XIMPreeditPosition: over-the-spot
XIMPreeditArea: off-the-spot
XIMPreeditNothing
現在のGLFWで使われているフラグです。
コールバックやプロパティは一切設定できませんが、Ubuntuでは一応IMEのウィンドウが表示されるようになっています。
イベントが正しく送られてこない(文字列変換中もEnterのイベントが送られてくる)ので、実装によっては正しく動作しません。
GLFWベースのMinecraftがいい例で一見日本語入力ができるように振る舞いますが、文字変換後に確定しようとしてEnterキーを押すと先にEnterキーのイベントが処理されてうまく入力できません。
XIMPreeditPosition
IME側で文字列をレンダリングします。
前編集属性のSpotLocationを変更することでカーソル位置を変更できるようになっています。
Ubuntuの場合、変換候補、変換中文字列が入った吹き出しのようなウィンドウが表示されます。
XIMPreeditCallbacks
一番使い慣れてる、IMEらしく見えるものです。
IME側で何も表示しない代わりに、コールバックで値を受け取ってアプリ側で前編集文字列をレンダリングします。
XIMPreeditPositionのようにカーソル座標を入力エンジンに通知する方法はありません。
登録できるコールバックは以下のとおりです。
前編集表示開始コールバック
void PreeditStartCallback(XIC,XPointer,XPointer)
前編集表示終了コールバック
void PreeditDoneCallback(XIC,XPointer,XPointer)
前編集描画コールバック
void PreeditDrawCallback(XIC,XPointer,XIMPreeditDrawCallbackStruct*)
前編集キャレット制御コールバック
void PreeditCaretCallback(XIC,XPointer,XIMPreeditCaretCallbackStruct*)
XIMPreeditDrawCallbackStructにはカーソル位置と変更範囲、変更するテキストが格納されています。
ただし、検証したFcitxとIBusではPreeditCaretCallbackが呼ばれることはありませんでした。
IBusではカーソル位置の文字列一番右に固定になるという謎仕様になっています。(内部でカーソルは動いてる)
XVaNestedListに各コールバックの関数ポインタを格納して、XCreateICのXNPreeditAttributesに設定をすることでコールバックを登録することができます。
XIMCallback preeditStart, preeditDraw, preeditDone, preeditCaret;
preeditStart.client_data = client_data;
preeditStart.callback = (XIMProc)preeditStartCallback;
preeditDone.client_data = client_data;
preeditDone.callback = (XIMProc)preeditDoneCallback;
preeditDraw.client_data = client_data;
preeditDraw.callback = (XIMProc)preeditDrawCallback;
preeditCaret.client_data = client_data;
preeditCaret.callback = (XIMProc)preeditCaretCallback;
XVaNestedList list = XVaCreateNestedList(0,
XNPreeditStartCallback, &preeditStart.client_data,
XNPreeditDoneCallback, &preeditDone.client_data,
XNPreeditDrawCallback, &preeditDraw.client_data,
XNPreeditCaretCallback, &preeditCaret.client_data,
NULL);
XIC ic = XCreateIC(im,
~,
XNPreeditAttributes,
list,
~);
XIMFeedbackの仕様が曖昧
XIMPreeditDrawCallbackStructのXIMTextの中にある、XIMFeedbackに変換候補のスタイルが格納されています。
しかし、これの仕様が曖昧で各入力メソッド,クライアントの実装依存になっています。
上記記事のOpenOfficeの実装を参考にして、OpenSiv3Dではこんな感じに対応しました。
0 -> 直前のスタイルを引き継ぐ
XIMReverse -> 色反転
XIMHighlight -> 下線(破線)
XIMUnderline -> 下線(実線)
その他 -> スタイルなし
そもそもXIMを使ってない今のアプリたち
GtkやQtには専用の入力エンジンと通信できる仕組みがあります。
Fcitx,IBusを確認すると、Gtk,Qt,XIMとそれぞれのフォルダを用意して個別に実装を行っているようでした。
IBusの場合、XIMはibus-x11というデーモンが担当しています。
試しにibus-x11をキルしたところ、Gtk,Qtベースのアプリは日本語入力できるのにXIMを使ったアプリは日本語入力できなくなりました。
XIMで融通が効かないのにGtkなどでちゃんと表示できるのはXIMを使っていないからのようです。
実装
追加実装したAPI
IMEの有効/無効切り替え
Platform::Linux::TextInput::EnableIME()
, Platform::Linux::TextInput::DisableIME()
IMEのフォーカス状態を変更します。
カーソル位置の取得
Platform::Linux::TextInput::GetCursorIndex()
編集中文字列の中のカーソル位置です。IBusの実装の都合上、カーソル位置は固定になることがあります。
編集中文字列のスタイル取得
Platform::Linux::TextInput::GetEditingTextStyle()
編集中文字列(TextInput::GetEditingText()
)の文字に対応したスタイルの配列を返します。
サンプル
# include <Siv3D.hpp> // OpenSiv3D v0.6.0
void Main()
{
Scene::SetBackground(ColorF{ 0.8, 0.9, 1.0 });
const Font font{ 20 };
bool enabled = true;
TextEditState state;
while (System::Update())
{
ClearPrint();
SimpleGUI::TextBox(state, {10,10});
auto cursor = Platform::Linux::TextInput::GetCursorIndex();
auto& style = Platform::Linux::TextInput::GetEditingTextStyle();
for(auto [idx, c] : Indexed(TextInput::GetEditingText()))
{
Print << U"{}, {:04b} {}"_fmt(c, static_cast<int32>(style[idx]), cursor == idx ? U"<-" : U"");
}
if(SimpleGUI::Button(enabled ? U"Disable" : U"Enable", {10, 50}))
{
if(enabled) {
Platform::Linux::TextInput::DisableIME();
} else {
Platform::Linux::TextInput::EnableIME();
}
enabled = !enabled;
}
}
}
その他調べたことなど
- XIMの資料を探すと7-10年前くらい前の情報ばかり見つかった(当時小学生くらい)
- X11のAPIはエラー処理が不十分なのでXcbを使うことも勧められたが、今回はGLFWの改良がメインだったので使うことはできなかった。
- IBus、Fcitxなどと直接通信する方法もあるみたいだけど、入力メソッドが増えれば増えるだけ対応しないといけないので非現実的。
(Firefoxのソースコードが印象的。対応する入力メソッドの数だけelse-ifを繰り返してる)
https://github.com/mozilla/gecko-dev/blob/19439d5e537ee566294c1c24252873259d9207f3/widget/gtk/IMContextWrapper.cpp#L467-L514 - Gtkのイベントループを組み込んで、GtkIMContextを使う線も模索したけどX11のウィンドウハンドルからGtkWindowを作ることができないので断念。
https://stackoverflow.com/questions/64517924/how-do-you-create-a-gtk-window-handle-from-an-x11-window-handle - 英語力と気力があればGLFWにプルリク投げたい
これからLinuxのアプリを作りたくて、日本語入力に対応したい方はできるだけ素のX11なんか触らずに大人しくGtkかQtを使うことをおすすめします。
参考資料
Discussion
KeyHoleTVの開発者です。 GLFWを利用して Android版のKeyHoleTVのテスト環境をLinux (Ubuntu)で構築しています。 Ubuntu 18.04.6 LTSで提供されるGLFWでは、ご指摘のようにIMEのウィンドウが表示され、リターンをヒットした後、コールバックが送られてきません。 しかし、glfw-3.3.6 をダンロード・解凍し、Cmakeをかけてmakeしたところ、IMEウィンドウで、変換した文字列がglfwSetCharCallbackで設定したコールバックにUTF-16で一文字づつ戻ってきます。