IME は自前で受けない。描いた枠の上に、本物の入力欄を一つだけ重ねる
We don't handle the IME ourselves: one real input box laid over the drawn frame
D13-3-T25-ime-inside-the-input-field・技術・日本語入力でプレイする仕組み
ゲームの画面は全部、自前の描画です。入力欄の枠も、例文も、字も、キャンバスが描いています。
ただし日本語の変換だけは自分で受けません。編集している間だけ、UE の本物の入力欄を枠の上に一つ置き、IME を OS と UE に任せています。
日本語入力で捜査するゲームなので、入力欄で日本語が変換できなければ、何も始まりません。
最初の版はキーを自前で溜める作りで、日本語が入力できませんでした(2026 年 8 月 10 日に直した)。
今日は、その直し方と、直したあとに踏んだ焦点の罠を書きます。
この回の位置と、前後の回#
区切り: T25〜T29 画面と入力(5 回のうち 1 回目)
先に読むとよい回: T01(全体の構造と、使っている技術と言語)・T03(文字の正規化)
次に読む回: T26(音声入力でそのまま遊べる)
名前 — Input Method Editor#
- 正式名
- Input Method Editor(IME)、Windows の Text Services Framework(TSF)、Unreal Engine の ITextInputMethodSystem と ITextInputMethodContext
- 略称
- IME・TSF。古い Windows の口は IMM32(Input Method Manager)
- 日本語
- 日本語入力システム、インプットメソッド、テキストサービスフレームワーク
- 初出・出所
- IME と TSF は Microsoft Learn の文書("Input Method Editors"・"Text Services Framework")。TSF の API の参照ページ(ITfComposition)は最小のクライアントを Windows 2000 Professional としていますが、TSF がいつ出たかは確かめられませんでした。ITextInputMethodSystem・ITextInputMethodContext は UE の ApplicationCore モジュールの GenericPlatform/ITextInputMethodSystem.h で、製品の API です
IME とは#
キーボードから入るキーの列を、かなや漢字に変換する部品です。
入力の途中には「未確定の文字列」(composition string)があり、変換の候補の窓が出て、確定すると文字が欄に入ります。
アプリの側から見ると、IME は次のものを要求します。
- 欄の中の文字列を読み書きさせること
- 選択範囲と、キャレットの位置
- いま変換中の範囲(どこからどこまでが未確定か)
- その範囲が画面のどこにあるか(候補の窓を出す位置)
- 変換の始まりと終わりの知らせ
Microsoft の文書は、アプリはふつう IME と直接やり取りしない、と書いています。
OS の標準のエディットコントロールを使えば、全部やってくれるからです。
TSF は、その仕組みを一般化したものです。
アプリ・テキストサービス・TSF マネージャの三つからなり、通信は必ず TSF マネージャを通ります。マネージャは OS が実装していて、置き換えられません。
テキストサービスには、キーボードの IME のほか、手書きや音声認識も入ります(音声入力の話は次の回 T26 で書きます)。
今のサードパーティの IME は TSF に対応していることが求められ、古い IMM32 の口は互換のために残っています。
よく知られた弱点は、ゲームのように自分で文字を描くアプリです。
OS の部品を使わないので、IME に渡すべき文字列・範囲・位置を、全部自分で答えなければならない。
答えないと、変換が始まらない、候補の窓が画面の隅に出る、変換中の Enter で送信されてしまう、といった事故が起きます。
UE の ITextInputMethodSystem#
UE はこの橋渡しを、二つのインタフェースに分けています。
ITextInputMethodSystem は、OS の側です。
RegisterContext・UnregisterContext・ActivateContext・DeactivateContext・IsActiveContext・ApplyDefaults を持ち、プラットフォームごとに実装されます。
Windows の実装 FWindowsTextInputMethodSystem は、初期化で IMM と TSF の両方を立てます(InitializeIMM() && InitializeTSF())。TSF の側は FTextStoreACP が ITextStoreACP を実装しています。
ITextInputMethodContext は、入力欄の側です。
UE 5.8.0 のヘッダにある純粋仮想関数を並べると、IME が要求するものがそのまま見えます。
IsComposing / IsReadOnly / GetTextLength
GetSelectionRange / SetSelectionRange
GetTextInRange / SetTextInRange
GetCharacterIndexFromPoint / GetTextBounds / GetScreenBounds / GetWindow
BeginComposition / UpdateCompositionRange / EndComposition
Slate の SEditableText は、中の FSlateEditableTextLayout がこれを実装しています。
キーボードの焦点を得たとき、自分の文脈を登録して有効にします(SlateEditableTextLayout.cpp)。
TextInputMethodChangeNotifier = TextInputMethodSystem->RegisterContext(TextInputMethodContext.ToSharedRef());
…
// Make sure to set Native OS window focus as well to ensure IME support
if (TSharedPtr<FGenericWindow> NativeWindow = TextInputMethodContext->GetWindow())
{
NativeWindow->SetWindowFocus();
}
TextInputMethodSystem->ActivateContext(TextInputMethodContext.ToSharedRef());
うちの入力欄#
画面は、画面を JSON で書く UI フレームワーク MomijiUI で描いています(T28)。
本編の画面の JSON(Game/ui/adv/workspace.json)には、文字欄(TextInput)が 3 つあります。質問の欄が 1 つ、手帳の検索の欄が 2 つ。質問の欄はこうです。
{"type":"TextInput","id":"caseInput","flex":1,"h":52,"bindVar":"caseui.inputDraft","action":"caseui:submit","fontSize":16,"placeholder":"思ったことを入力して Enter"}
(連載の 1 回目 T01 では入力欄を SEditableTextBox と書きましたが、それは画面の JSON が読めなかったときに使う予備の画面の部品で、いまの本編の欄はこの TextInput です。)
自前で IME に答えるには、ITextInputMethodContext を全部実装することになります。
変換の範囲、キャレット、選択、候補の窓の位置まで。
それは SEditableText を書き直すのと同じです。
だから、本物を一つだけ借りることにしました。SKaedeUiCanvasTextInput.cpp の冒頭の注記です。
// 編集は ChildSlot の SEditableText に任せる。(中略)UE で IME を自前対応するには
// ITextInputMethodContext の実装が要り、変換範囲・キャレット・選択・候補ウィンドウ位置まで
// 面倒を見ることになる= SEditableText の再実装。だから本物を1個だけ重ねる。
キャンバス(SKaedeUiCanvas)の子は SEditableText が 1 つだけです。
SAssignNew(TextEditor, SEditableText)
// 編集中のフィールドが無い間は畳む=レイアウトにも当たり判定にも出てこない。
.Visibility_Lambda(this
{
return ActiveTextField.IsEmpty() ? EVisibility::Collapsed : EVisibility::Visible;
})
// 枠と背景はキャンバスが描いているので、ここは文字だけ。倍率は最後に描いたときの値。
.Font_Lambda(this { … })
.HintText_Lambda(this { …(キャンバスが描く例文と同じ文)… })
.OnTextChanged_Lambda([this](const FText& T) { OnEditorTextChanged(T); })
.OnTextCommitted_Lambda([this](const FText& T, ETextCommit::Type Type) { … })
枠・背景・焦点の輪はキャンバスが描き、SEditableText は字だけを描きます。
欄が画面にいくつあっても、編集器は 1 つで、編集中の欄の位置へ動かします。
欄ごとの中身は、キャンバスが bindVar の名前ごとに持ち続けます。
位置合わせは OnArrangeChildren で、描画と同じレターボックス(論理 1280×720 を縦横比を保って拡大)を使います。
縦は枠いっぱいにしません。SEditableText は与えられた高さの上端から字を描くので、枠の 52 をそのまま渡すと字が上に寄ります。
非編集時の描画と同じ式で、全角 1 字「あ」の字面の中心から 1 行の帯を置き、編集に入っても字が動かないようにしています。
SEditableText が実際の画面の矩形にいるので、IME の候補の窓は、欄のすぐ下に出ます。
踏んだ罠: 焦点は「成功」したのに日本語が入らない#
Enter で質問を送ったあと、続けて日本語が入らない、という不具合が続きました。
焦点を渡す関数 SetKeyboardFocus の戻り値はずっと成功(ok=1)で、ログで 153 回とも ok=1 でした。
原因は、Enter のたびに編集器を畳んでいたことです。
畳む(Collapsed)と IME の文脈が降りる。外側がすぐ焦点を戻すと、その時点ではまだ配置されていない、畳まれたままの部品に対して焦点の移動が「成功」する。
見た目には欄に焦点があるのに、IME の文脈は降りたまま、という状態でした。
直し方は、畳む・出すの往復をやめることでした。
// 往復そのものを無くすのが直し方なので、欄を生かしたまま action だけ発火する。
void SKaedeUiCanvas::CommitTextField(bool bEnter)
{
…
// 送信しない確定(Escape・action の無い名前入力欄など)は従来どおり編集を抜ける。
if (Action.IsEmpty()) { ActiveTextField.Reset(); …; return; }
// 中身は外殻が空にする(SetTextInputValue → SyncTextEditor(false) はフォーカスを奪わない)。
if (OnAction) { OnAction(Action, 0.f); }
}
送信する欄では編集器を生かしたまま、action(caseui:submit)だけを外側へ渡します。
外側(SKaedeAdvShell)は欄の中身を空にし、本編が入力を受けられるときだけ焦点を戻します。
焦点を戻すときも戻り値を見て、失敗したら次のフレームでもう一度だけ試します。
const bool bOk = FSlateApplication::Get().SetKeyboardFocus(TextEditor, EFocusCause::Mouse);
UE_LOG(LogKaedeUiTextInput, Display, TEXT("[ui] textfield focus id=%s ok=%d"), *ActiveTextField, bOk ? 1 : 0);
if (!bOk) { RegisterActiveTimer(0.f, …(1 回だけ再試行)…); }
もう一つ、編集器を付けてよい欄を絞りました。
押せる(enabled)、最前面の群にある(確認の窓が開いていればその中)、スクロールの見える範囲に少しでも出ている。
この三つを満たす欄だけに編集器を付け、満たさなくなったらその場で編集を降ります。
これが無かったころ、確認の窓の背面の欄に焦点が残り、背面から Enter で送信できてしまいました。
「文字を消す」で入力欄ごと画面から外したときに、見えない編集器がキーを飲む、という事故もありました。
ブラウザ版#
ブラウザ版も同じ形です。
キャンバス(<canvas>)に枠を描き、文字欄の位置に本物の <input> を重ねて、IME はブラウザに任せます。
変換中の Enter は、確定であって送信ではありません。momiji-ui.mjs の keydown です。
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' || ev.isComposing || ev.keyCode === 229) return; // IME の変換中の Enter は確定であって送信ではない
ev.preventDefault();
…(送る直前に、その欄がいま操作できるかをもう一度確かめる)…
});
isComposing だけでなく keyCode 229 も見るのは、変換中のキーをこの値で知らせるブラウザがあるからです。
編集を降りるときは、blur に加えて選択範囲も消します。
blur だけだと Chrome は欄の中の選択を持ち続け、IME の確定文字が焦点の無い欄に入った、という実測がありました(10 月 4 日)。
UE とブラウザの両方で、文字欄の扱いは同じ規則(操作できる欄だけ・見える範囲で切る・降りても下書きは残す)で、MomijiUI の仕様の一か所に書いてあります。
変換の結果は、そのまま核へ#
IME が確定した文字列は、そのまま照合の核に渡ります。
全角の「?」や読点は、核の正規化(norm/1)が畳みます。
今日、港で佐伯に聞いた結果です。
- 「昨日の夜は、どこに居たんですか?」→ 着地(0.941)
- 「昨日の夜は、どこにいたんですか?」→ 着地(1.065)
- 変換しないひらがな「きのうのよるはどこにいた」→ 着地(0.432。採用の線は 0.33)
- 半角カナ「キノウノヨルハドコニイタ」→ 受け流し(最高 0.236)
- IME が切れたままのローマ字「kinou no yoru ha doko ni ita」→ 受け流し(最高 0.154)
半角カナは、norm/1 が全角に畳みません。T03 で書いたとおり、norm/1 は NFKC の部分集合ではなく、重なるのは全角英数字と全角空白だけだからです。
IME の設定で半角カナが出る人は少ないはずですが、出たら通じません。
名前どおりでない所 — Input Method Editor#
名前どおりです。
TSF も ITextInputMethodContext も自分では実装せず、UE とブラウザがそのまま使う口を、本物の入力欄ごと借りています。
MomijiUI の TextInput は IME の実装ではなく、「枠を描き、編集中だけ本物の入力欄をそこに置く」部品です。
弱点と、採らなかった案 — Input Method Editor#
弱点です。
- UE の IME の動きを、機械で確かめられていません。合成した OS のキー入力は Slate に届かないので、自動の検査は「欄の文字が変わったのと同じ経路」を直接呼んでいます。IME の変換そのものは通っていません。UE の上で日本語が変換できることは、人の手でしか見ていません。
- ブラウザの照合でも、本物の変換(DevTools の imeSetComposition)を通せるのは Chromium だけで、Firefox と WebKit は合成したイベントです。
- 編集器は 1 画面に 1 つです。二つの欄を同時に編集する画面は作れません(いまの作品には要りません)。
- 質問の欄に maxLen はありません。ブラウザ版のサーバは 200 文字を超えた入力を 400 で断るので、長く入力すると画面の上では入ったのに届かない、ということが起こりえます。
採らなかった案です。
- ITextInputMethodContext を自分で実装する。上に書いたとおり SEditableText の再実装です。
- キーを自前で溜める。最初の版がこれで、日本語が入りませんでした。
- 入力欄だけ、キャンバスの外の普通の Slate 部品として常に出しておく。以前の本編の画面はそうでした(いまの予備の画面がそれです)。ただ画面の JSON で欄の位置・大きさ・出す条件を決められず、ブラウザ版と同じ画面にできません。
- UMG の EditableText を使う。このプロジェクトは UMG を使わない方針です。
日本語を入力するのは、OS の仕事。
こちらは、それが届く場所を正しい位置に置いておくだけにしました。
検索語#
Text Services Framework
Input Method Editor
ITextInputMethodContext Unreal
KeyboardEvent isComposing
入力欄で変換して、Enter で聞く。『霧走町 流氷に眠る』の第一章はブラウザで、プロフィールのリンクからそのまま試せます。