2022-01-20 , ,

Termuxでハードウェアキーボードからスムーズに日本語入力したい

最近AndroidでEmacsが動いているのを見かけたので私も試してみました。

F-DroidからTermuxを入れて pkg install emacs だけでEmacsが動いて感激。普段使っているorg-modeをはじめ、あれもこれもほとんどそのまま動きます。楽しすぎる。思い切って普段使いの電話に入れたので、これでいつでもどこでもEmacsが使えます。

ソフトウェアキーボードで日本語も入力できました。日本語じゃないときはHacker's Keyboardに切り替えて使うのが便利です。

ハードウェアキーボードでの問題点

気を良くしてBluetoothキーボードを接続してみたのですが色々問題点が。それぞれ次のように対処しました。

Ctrl+Spaceが効かない
~/.termux/termux.propertiesctrl-space-workaround = true を追加。さらにAndroidの設定で物理キーボードのレイアウト候補を一つにする(複数あるとCtrl+Spaceが効いた上でレイアウトが変わるという…)。
CtrlとCaps Lockの入れ替え
106/109ハードウェアキーボード配列変更 (+親指Ctrl) [日本語配列] - Google Play のアプリ で解決。
Back Spaceの左のキーで¥(#xA5)が入力されてしまい\(#x5C)が入力できない
一時的に (keyboard-translate #xa5 #x5c) で回避しましたが、最終的には shiftrot/caps2ctrl を修正してビルド、インストールすることで解決。
Ctrl+Altで始まるキーの一部がTermuxに取られる
~/.termux/termux.propertiesdisable-hardware-keyboard-shortcuts = true を追加。

Input MethodのON/OFFをハードウェアキーボードから切り替える

その上で、Input Method(以下IM。私はATOKを使っています)がらみの問題が残りました。

  • ハードウェアキーボードからIM経由で(ツールバーの上の領域に直接)日本語が入力できない場合がある
  • ハードウェアキーボードからIMのON/OFFを切り替えられない (disable-hardware-keyboard-shortcuts = true にしたので)

下部ツールバーを左スワイプしてEditTextを出すとなぜか上の領域でも直接日本語が入力できるようになったりするのですが今度はそれを元の状態に戻せなくなったりと何かとうまくいきませんでした(細かい状況は再現して確認するのが面倒なので省きます)。AndroidのIMを捨てるという手もあるのかもしれませんが、かな入力をしていることもありますし学習の同期のこともありますからできればATOKが使いたいです。

幸いTermuxはソースコードが公開されているので調査したところ、次の場所に原因があることが分かりました。

terminal-view/src/main/java/com/termux/view/TerminalView.java

    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        // Ensure that inputType is only set if TerminalView is selected view with the keyboard and
        // an alternate view is not selected, like an EditText. This is necessary if an activity is
        // initially started with the alternate view or if activity is returned to from another app
        // and the alternate view was the one selected the last time.
        if (mClient.isTerminalViewSelected()) {
            if (mClient.shouldEnforceCharBasedInput()) {
                // Some keyboards seems do not reset the internal state on TYPE_NULL.
                // Affects mostly Samsung stock keyboards.
                // https://github.com/termux/termux-app/issues/686
                // However, this is not a valid value as per AOSP since `InputType.TYPE_CLASS_*` is
                // not set and it logs a warning:
                // W/InputAttributes: Unexpected input class: inputType=0x00080090 imeOptions=0x02000000
                // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:packages/inputmethods/LatinIME/java/src/com/android/inputmethod/latin/InputAttributes.java;l=79
                outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
            } else {
                // Using InputType.NULL is the most correct input type and avoids issues with other hacks.
                //
                // Previous keyboard issues:
                // https://github.com/termux/termux-packages/issues/25
                // https://github.com/termux/termux-app/issues/87.
                // https://github.com/termux/termux-app/issues/126.
                // https://github.com/termux/termux-app/issues/137 (japanese chars and TYPE_NULL).
                outAttrs.inputType = InputType.TYPE_NULL;
                // ★↑ここがTYPE_NULLだとTerminalViewにハードウェアキーボード+IMEで日本語が入力できない。
                // outAttrs.inputType =  InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL; に変更する。
            }
        } else {
            // Corresponds to android:inputType="text"
            outAttrs.inputType =  InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL;
        }

原因は outAttrs.inputType = InputType.TYPE_NULL という部分。これは受け付けるテキストの種類(単なるテキストなのか、数値なのか、パスワードなのか、URLなのか等々)を指定する部分です。TYPE_NULLを指定するとIMは変換を行わずキーイベントを垂れ流すようになるみたいです。なのでこの状態でハードウェアキーボードから日本語が入力できないのは当然です。

そこで、ここを下のelse部分と同じようにInputType.TYPE_CLASS_TEXTを設定するように変更したところ、ソフトウェアキーボードが出さえすれば直接上の領域(TerminalView。下部ツールバーではない部分)に日本語が入力できるようになりました(ただし確定前の文字列は表示されません(後述))。

次にキーボードからIMのON/OFFを切り替えられるようにする必要があります。日本語ではない部分で常に文字列を確定させながら打たなければならないのはかったるくて仕方がありませんからね。Ctrl+Alt+Kというショートカットが用意されていたのですが上で書いたように無効化してしまったので使えません。

そのために次のように変更しました。

app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java

     @Override
     public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession) {
+        if (e.getAction() == KeyEvent.ACTION_DOWN &&
+            (e.getKeyCode() == KeyEvent.KEYCODE_HENKAN && KeyEvent.metaStateHasModifiers(e.getModifiers(), KeyEvent.META_CTRL_ON))){ //Ctrl+変換
+            onToggleSoftKeyboardRequest();
+            return true;
+        }
+        //この下に disable-hardware-keyboard-shortcuts で無効化される処理があるので、それ以前にCtrl+変換の処理を行うこと。
+
         if (handleVirtualKeys(keyCode, e, true)) return true;

Ctrl+変換でソフトウェアキーボードをトグルするようにしてみました。私は普段PCでもCtrl+変換でIMEをON/OFFしています。PC-98の名残です。CTRL+XFER。人によっては半角/全角に割り当ててもいいかもしれません。(2022-01-22追記:半角/全角に割り当てるとcaps2ctrlとの兼ね合いでかな/英字切り替えに不都合が出るかもしれません。caps2ctrlは結構いろんなキーを半角/全角にリマップしています)

さらに ~/.termux/termux.propertiessoft-keyboard-toggle-behaviour = enable/disable の指定が 必要 です。デフォルトのshowing/hidingだと隠すだけなので何かを入力するとすぐに復活してしまいます。

それと下部のツールバーですが、もし左スワイプして出てくるEditText(テキストボックス)にフォーカスがあるのならTABキーで上の領域にフォーカスを移せます。移せないようならキーボード操作で移せるように改造しようと思ったのですが不要でした。

後はビルドですが、たまたま手元のPCに入っていたAndroid Studioでgit cloneしてきたディレクトリのトップを開いてRunボタンを押したらすんなり成功してしまいました。超簡単です。デバッグビルドでも事足りるのかもしれませんが、一応自前の署名を施してリリース版のapkを作成し、実機にインストールしました。署名が変わるので他で入手したバージョンはいったんアンインストールする必要があります(必要ならデータをバックアップすること)。

これでハードウェアキーボードからIMをON/OFFできるようになりました。ONのときは日本語入力ができますし、OFFのときは煩わしい変換処理無しに直接任意のキーが入力できます。

ソフトウェアキーボードから変換確定せずに入力できなくなった

ここでいったん手を止めて外出したときにソフトウェアキーボードだけで操作してみようとしたのですが、ソフトウェアキーボードから変換せずに直接キーを入力できないことに気がつきました。Hacker's Keyboardですら文字の確定が必要になってしまいました。原因はもちろん inputType を TYPE_CLASS_TEXT にしたからです。ただ、これはHacker's Keyboardに何とかして欲しい気がします。このキーボードのコンセプト的にどんなときでも変換を通さない直接入力ができてしかるべきでしょう。他のアプリでもそういうシチュエーションはありそうです。というわけでHacker's Keyboardの設定を調べたら「入力候補を表示」というチェックボックスがありました。解除したところ確定の必要無しに直接キーを入力できるようになりました。

inputType を TYPE_CLASS_TEXT にしたのが原因なので、そこを修正するという手もあります。例えばCtrl+変換でinputType自体を切り替えてしまうというのはどうでしょう。

terminal-view/src/main/java/com/termux/view/TerminalView.java

@@ -28,6 +29,7 @@ import android.view.autofill.AutofillValue;
 import android.view.inputmethod.BaseInputConnection;
 import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
 import android.widget.Scroller;
 
 import androidx.annotation.RequiresApi;
@@ -85,6 +87,8 @@ public final class TerminalView extends View {
 
     private static final String LOG_TAG = "TerminalView";
 
+    private int mInputType = InputType.TYPE_NULL;
+
     public TerminalView(Context context, AttributeSet attributes) { // NO_UCD (unused code)
         super(context, attributes);

@@ -279,7 +286,7 @@ public final class TerminalView extends View {
                 // https://github.com/termux/termux-app/issues/87.
                 // https://github.com/termux/termux-app/issues/126.
                 // https://github.com/termux/termux-app/issues/137 (japanese chars and TYPE_NULL).
-                outAttrs.inputType = InputType.TYPE_NULL;
+                outAttrs.inputType = mInputType;
             }
         } else {
             // Corresponds to android:inputType="text"
@@ -704,6 +717,18 @@ public final class TerminalView extends View {
             stopTextSelectionMode();
         }
 
+        // Ctrl+変換で inputType を切り替える。切り替え方法はTextViewのsetInputTypeメソッドを参考のこと。
+        if (event.getAction() == KeyEvent.ACTION_DOWN &&
+            (event.getKeyCode() == KeyEvent.KEYCODE_HENKAN && KeyEvent.metaStateHasModifiers(event.getModifiers(), KeyEvent.META_CTRL_ON))){ //Ctrl+螟画鋤
+            mInputType = mInputType == InputType.TYPE_NULL
+                ? (InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL)
+                : InputType.TYPE_NULL;
+            // Input Methodをリスタートする(参考: TextView.setInputType()のソース)
+            InputMethodManager imm = getContext().getSystemService(InputMethodManager.class);
+            if (imm != null){imm.restartInput(this);}
+            return true;
+        }
+

これでもハードウェアキーボードから入力方式を切り替えることができました。

ただ、ハードウェアキーボードが無しでもinputTypeを切り替える方法を別途用意しないと意味がありません(Volume Up+何かのキーに割り当てるとか? サイドバーにボタンを追加するとか?)。

それとこの方式はIMはずっと有効にしたまま(inputTypeを切り替えて)使うことになるので、ハードウェアキーボード使用時でIMが不要なときでも無駄に画面の下部をソフトウェアキーボードが占有してしまいます(ATOKの場合物理キーボード入力時は小さく折りたたまれるとはいえ)。ハードウェアキーボードから使うなら、どちらかと言えばIM自体をON/OFFしたほうが素直な気がしました。

この辺はどのソフトウェアキーボードを使いたいかにもよるでしょう。Hacker's Keyboard以外のソフトウェアキーボードで確定無し入力がしたいなら、やはりinputTypeを切り替えるUIを追加してTYPE_NULLにもできるようにすべきだと思います。

ハードウェアキーボードを考慮しないのであれば、上(TerminalView)にフォーカスがあるときは直接入力(TYPE_NULL)、下(ツールバーのEditText)にあるときはIM入力(EditTextは当然TYPE_CLASS_TEXTになっています)という使い分けができます(おそらくこれが本来の意図でしょう)。しかしハードウェアキーボードを使う場合は常に上にフォーカスを当てていたいのです。Ctrl+変換でフォーカスを移動するという手もあるのですが、下にフォーカスがあるときに日本語を入力して確定してからもう一度Enterをおさないといけなかったり、IMが有効でもできた(移動等の)操作がIMを切らないと(フォーカスを上に戻さないと)出来なくなったり、とにかく下で入力するのは不便なのでやめました。考えてみれば入力領域が二つに分かれているのも変な話で、やはり本来入力領域は一つにするのが筋でしょう。

(2022-01-22追記: ツールバー内のExtra Keys Rowにテキスト種別切り替えボタンがあると良いのでは? ソフトウェアキーボードのenable/disableの切り替えボタンなんかもここにあるといいかも)

確定前文字列のプレビュー

確定前の文字列が表示されない問題ですが、出なくても概ね打てるので私は許容範囲内なのですが、気になるなら自分でプレビューを実装すれば良いのだと思います。BaseInputConnectionのendBatchEditあたりをオーバーライドすれば確定前文字列の変化をフックできそうです。とりあえず getEditable().toString() をログ出力してみたら確定前文字列が取得できるところまで確認しました。ここで自前で追加したTextViewにでも表示してしまえばとりあえず見られるようにはなると思います。何ならツールバーのEditTextをプレビュー用に使ってしまう手もあるでしょう。ターミナル内のカーソル位置に他の文字と同じような見た目で表示するにはもっと色々調べる必要がありそうです。

この辺りをいじるとAndroidのIMまわりに少し詳しくなれそうですよ。

caps2ctrlのビルド

上で触れたCapsとCtrlを入れ替えるアプリですが、ソースはGitHubにあります。これもAndroid Studioで簡単にビルドできました。自分の好きなキーボードレイアウトにしたい人は是非ビルドしてみましょう。私はバックスラッシュと無変換キーを変更しました。

shiftrot/caps2ctrl: Add a physical keyboard layout for Android to replace Caps Lock with Ctrl.

Termuxといいcaps2ctrlといい、ソースコードが公開されていて本当に助かりますね。先ほどのinputTypeの所を見ると沢山のissueコメントがくっついています。その多くは開発者が自分では使わない環境の問題でしょうに。その対処だけでも私なら疲弊してしまいそうです。本当に頭が下がります。

現在の環境

org-modeが使えて楽しすぎる
スタバでドヤれそうな環境

キーボードはMOBO Keyboard2です。私は日本語配列Loveなので待ちに待った折りたたみキーボードです。(カバーを除く)キーボード部分だけならば278g。これなら登山にだって持って行けそう。

UserLAnd?

ところでUserLAndというアプリもあるらしいのですが、こちらはどうなのでしょう。Termuxよりも重いけれど問題は少ないという話を見かけましたが……。

2022-01-21追記:確定前文字列のプレビュー

あくまで一例ですが…… (v.0.118.0からの修正)

app/src/main/res/layout/activity_termux.xml (画面下部にプレビュー用のTextViewを追加)

@@ -101,6 +101,16 @@
 
     </RelativeLayout>
 
+    <TextView
+        android:id="@+id/activity_termux_composing_text"
+        android:visibility="gone"
+        android:layout_width="match_parent"
+        android:layout_height="24dp"
+        android:background="#202020"
+        android:textColor="#ffffff"
+        android:textSize="18dp"
+        />
+
     <View
         android:id="@+id/activity_termux_bottom_space_view"
         android:layout_width="match_parent"

terminal-view/src/main/java/com/termux/view/TerminalView.java (変換中の文字列をClientへ送信)

@@ -292,6 +298,12 @@ public final class TerminalView extends View {
 
         return new BaseInputConnection(this, true) {
 
+            @Override
+            public boolean endBatchEdit() {
+                mClient.onComposingTextChange(getEditable());
+                return super.endBatchEdit();
+            }
+
             @Override
             public boolean finishComposingText() {
                 if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "IME: finishComposingText()");
@@ -299,6 +311,7 @@ public final class TerminalView extends View {
 
                 sendTextToTerminal(getEditable());
                 getEditable().clear();
+                mClient.onComposingTextChange(getEditable());
                 return true;
             }
 
@@ -314,6 +327,7 @@ public final class TerminalView extends View {
                 Editable content = getEditable();
                 sendTextToTerminal(content);
                 content.clear();
+                mClient.onComposingTextChange(getEditable());
                 return true;
             }
 

terminal-view/src/main/java/com/termux/view/TerminalViewClient.java (Clientへ送信するためのインタフェースを追加)

@@ -1,5 +1,6 @@
 package com.termux.view;
 
+import android.text.Editable;
 import android.view.KeyEvent;
 import android.view.MotionEvent;
 import android.view.ScaleGestureDetector;
@@ -59,6 +60,8 @@ public interface TerminalViewClient {
     boolean readFnKey();
 
 
+    void onComposingTextChange(Editable text);
+
 
     boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session);
 

termux-shared/src/main/java/com/termux/shared/terminal/TermuxTerminalViewClientBase.java (Clientのデフォルトの実装を追加)

@@ -1,5 +1,6 @@
 package com.termux.shared.terminal;
 
+import android.text.Editable;
 import android.view.KeyEvent;
 import android.view.MotionEvent;
 
@@ -78,6 +79,11 @@ public class TermuxTerminalViewClientBase implements TerminalViewClient {
     }
 
 
+    @Override
+    public void onComposingTextChange(Editable text)
+    {
+    }
+
 
     @Override
     public boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session) {

app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java (クライアントは送られてきた文字列をTextViewに表示。後は表示非表示の切り替えなど)

@@ -10,6 +10,7 @@ import android.content.Intent;
 import android.media.AudioManager;
 import android.net.Uri;
 import android.os.Environment;
+import android.text.Editable;
 import android.text.TextUtils;
 import android.view.Gravity;
 import android.view.InputDevice;
@@ -18,6 +19,7 @@ import android.view.MotionEvent;
 import android.view.View;
 import android.widget.EditText;
 import android.widget.ListView;
+import android.widget.TextView;
 import android.widget.Toast;
 
 import com.termux.R;
@@ -70,6 +72,8 @@ public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase {
 
     private boolean mTerminalCursorBlinkerStateAlreadySet;
 
+    private TextView mComposingText;
+
     private static final String LOG_TAG = "TermuxTerminalViewClient";
 
     public TermuxTerminalViewClient(TermuxActivity activity, TermuxTerminalSessionClient termuxTerminalSessionClient) {
@@ -87,6 +91,7 @@ public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase {
     public void onCreate() {
         mActivity.getTerminalView().setTextSize(mActivity.getPreferences().getFontSize());
         mActivity.getTerminalView().setKeepScreenOn(mActivity.getPreferences().shouldKeepScreenOn());
+        mComposingText = mActivity.findViewById(R.id.activity_termux_composing_text);
     }
 
     /**
@@ -229,6 +234,12 @@ public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase {
     @SuppressLint("RtlHardcoded")
     @Override
     public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession) {
+        if (e.getAction() == KeyEvent.ACTION_DOWN &&
+            (e.getKeyCode() == KeyEvent.KEYCODE_HENKAN && KeyEvent.metaStateHasModifiers(e.getModifiers(), KeyEvent.META_CTRL_ON))){ //Ctrl+変換
+            onToggleSoftKeyboardRequest();
+            return true;
+        }
+
         if (handleVirtualKeys(keyCode, e, true)) return true;
 
         if (keyCode == KeyEvent.KEYCODE_ENTER && !currentSession.isRunning()) {
@@ -346,6 +358,12 @@ public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase {
     }
 
 
+    @Override
+    public void onComposingTextChange(Editable text)
+    {
+        mComposingText.setText(text);
+    }
+
 
     @Override
     public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSession session) {
@@ -501,10 +528,12 @@ public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase {
         if (mActivity.getProperties().shouldEnableDisableSoftKeyboardOnToggle()) {
             // If soft keyboard is visible
             if (!KeyboardUtils.areDisableSoftKeyboardFlagsSet(mActivity)) {
+                mComposingText.setVisibility(View.GONE);
                 Logger.logVerbose(LOG_TAG, "Disabling soft keyboard on toggle");
                 mActivity.getPreferences().setSoftKeyboardEnabled(false);
                 KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView());
             } else {
+                mComposingText.setVisibility(View.VISIBLE);
                 // Show with a delay, otherwise pressing keyboard toggle won't show the keyboard after
                 // switching back from another app if keyboard was previously disabled by user.
                 // Also request focus, since it wouldn't have been requested at startup by

一応最低限入力中の文字は見えます。

Pingback / Trackback