【Pyto】スマホをPC用フリックキーボードにしてみた


![image2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549600/88220d07-4b7b-21a3-bb1e-a9808d179553.jpeg)

## はじめに

 若者のPC離れと言われる中、タイピングができない人が多くなってきているそうです。しかし若者はPCが使えない代わりにスマホの扱いには慣れており、現在の女子高生のフリック入力はガラケーギャルもびっくりするほどの速さです。  

それならPCの入力もフリックでやってしまえば良いのではないか、ということは以前から言われていたようですが、手軽に試せるソフトはありませんでした。  

そこで、iOSでPythonプログラミングができる[Pyto](https://pyto.app/)というアプリを使って、フリックキーボードを作ってみました。

## 既存の製品
- [FlickTyper](http://flicktyper.com)  
スマホとPCをこの装置で繋ぐとスマホがフリックキーボードになります。また、Bluetoothを使って無線で接続できるFlickTyperBTもあります。PC側に専用ドライバのインストールが不要である点が手軽で良さそうです。
- [Google日本語入力 物理フリックバージョン](https://www.google.co.jp/ime/furikku/)  
2016年にGoogleが出した、エイプリルフールのネタです。
- [物理フリックキーボード](https://www.switch-science.com/catalog/3557/)  
上のGoogleのネタが実用的になったようなキーボード組み立てキットです。個人で製作されたものだそうです。Switch Scienceで受託販売されていました。

他にも、タッチパネルを使ってフリックキーボードを作成しようとされている画像を見つけました。

## 環境
[iOS側]
iPhone SE 2020 (iOS13.4.1)
Pyto (12.0.3)

[PC側]
Windows 10 Home 64bit (1909)
Python 3.7.4

ライブラリ
pyautogui (0.9.50)
pyperclip (1.8.0)



## 仕様
ソケット通信を使ってiOS端末の入力データをPCに送ります。

機能は文字入力、Enter、Backspaceです。十字キーやDelete等の機能をつけることも可能ですが、今回は文字入力に限定しました。

漢字の変換はiOS側で行い、変換確定後、再度確定を押すことでPCに文字を送ります。

## ソースコードと解説
ソースコード全体は [GitHub](https://github.com/ym0268/flick_keyboard) に置いています。
iOS側が2ファイル、PC側が1ファイルです。

### iOS側

#### text_client.py

``` python
# text_client.py
import socket

class Sender():
    def __init__(self, port, ipaddr, timeout=5.0):
        self.port = port
        self.ipaddr = ipaddr
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.settimeout(timeout)
    
    def connect(self):
        self.sock.connect((self.ipaddr, self.port))            
    
    def send(self, text, enc="utf-8"):
        """
        データを送信する
        
        Parameters
        ----------
        text : str
            送信するテキスト
        enc : str
            エンコードのタイプ。Noneでエンコードなし
        """
        if(enc is not None):
            text_ = text.encode(enc)
        else:
            text_ = text
        self.sock.sendall(text_)
    
    def close(self):
        self.sock.close()
 
```
テキスト送信クラスです。ソケット通信を使ってデータを送ります。これを2つ目のファイルで使います。

#### flick_kb.py

```python
# flick_kb.py
import sys
import socket
import pyto_ui as ui
import text_client as myclient

class MySender(myclient.Sender):
    def __init__(self, port, ipaddr, timeout=5.0):
        print(port, ipaddr)
        super().__init__(port, ipaddr, timeout)
        self.editflag = False
    
    def connect(self):
        try:
            super().connect()
            self.editflag = True
            print("connection")
        except socket.timeout:
            print("timed out")
            super().close()
            sys.exit()
    
    def send_text(self, text):
        self.send(text)
    
    def send_end_flag(self):
        # アプリ終了をPCに伝えるフラグを送信
        if(self.editflag):
            self.send(b"\x00", enc=None)
            self.editflag = False
            
    def close(self):
        self.send_end_flag()
        super().close()
    
    def did_end_editing(self, sender):
        if(self.editflag):
            if sender.text == " ":
                self.send(b"\x0a", enc=None)  # enter
                sender.text = " "
            elif sender.text == "":
                self.send(b"\x08", enc=None)  # backspace
            else:
                self.send_text(sender.text[1:])  # 前のスペースを省いてテキスト送信
                sender.text = " "
    
        sender.superview["text_field1"].become_first_responder()  # テキストボックスからフォーカスが外れないようにする
    
    def did_change_text(self, sender):
        if sender.text == "":
            sender.text = " "  # バックスペース検知用
            self.send(b"\x08", enc=None)  # backspace       

def main():
    args = sys.argv
    if(len(args)<=2):
        print("Input arguments. [PORT] [IP Address]")
        sys.exit()
    else:
        port = int(args[1])
        ipaddr = args[2]
    
    # 送信部
    print("start connection...")
    mysender = MySender(port=port, ipaddr=ipaddr, timeout=5.0)
    mysender.connect()
    
    # GUI部
    view = ui.View()
    view.background_color = ui.COLOR_SYSTEM_BACKGROUND
    
    text_field = ui.TextField(placeholder="Back Space")
    text_field.name = "text_field1"
    text_field.text = " "
    text_field.become_first_responder()
    text_field.action = mysender.did_change_text
    text_field.did_end_editing = mysender.did_end_editing
    text_field.return_key_type = ui.RETURN_KEY_TYPE_DONE
    text_field.width = 300
    text_field.center = (view.width/2, view.height/2)
    text_field.flex = [
        ui.FLEXIBLE_BOTTOM_MARGIN,
        ui.FLEXIBLE_TOP_MARGIN,
        ui.FLEXIBLE_LEFT_MARGIN,
        ui.FLEXIBLE_RIGHT_MARGIN
    ]
    view.add_subview(text_field)
    
    ui.show_view(view, ui.PRESENTATION_MODE_SHEET)
    
    # 終了処理
    mysender.close()
    print("end")

if __name__=="__main__":
    main()

```
フリックキーボード本体です。pytoの`pyto_ui.TextField`を使っています。GUI部はPytoのサンプルコードとほぼ同じです。  

この`TextField`ですが、確定を押すと自動的にフォーカスが外れてしまいます。それでは連続入力ができないため、`become_first_responder()`という関数を呼び出し、再度表示させるという方法をとっています。このため、確定キー押下後に一瞬だけキーボードが消えます。  

また、GUI部の`text_field.action`に設定している`mysender.did_change_text`メソッドは日本語の文字確定前でも呼び出されるので、変換を確定した瞬間に送信する、といったことができないと思われます。ゆえに「変換を確定→確定」と2回確定ボタンを押すことでテキストを送信するようにしました。改行を入れるには3回確定キーを押す必要があります。  

文字消去(Backspace)は何もテキストがない状態では反応しません。そこで、あらかじめテキストボックスにスペースを入れておくようにしました。このスペースが消された時にBackspaceが押されたと判定します。消された後は`sender.text = " "`という処理で、すぐに新しいスペースを入れています。

Enter、Backspace、終了コードはそれぞれUTF-8のLF (0x0a)、Backspace (0x08)、NULL(0x00)を使用しましたが、フラグとして使っているだけなので本来の意味はありません。そのため、改行コードはPCのOSやエディタの設定が反映されます。

### PC側

#### flick_kb_receiver.py

```python
# flick_kb_receiver.py
import sys
import time
import socket
import pyautogui
import pyperclip
import threading

def type_text(text):
    # 与えた文字を入力（クリップボードにコピー＆ペースト）
    pyperclip.copy(text)
    pyautogui.hotkey("ctrl", "v")
    return True

def type_backspace():
    pyautogui.typewrite(["backspace"])
    return True

def type_enter():
    pyautogui.typewrite(["enter"])
    return True

class Receiver():
    def __init__(self, port, ipaddr=None, set_daemon=True):
        """
        受信側

        Parameters
        ----------
        port : int
            使用するポート番号
        ipaddr : None or str
            受信側PCのIPアドレス．Noneで自動取得．
        set_daemon : bool
            スレッドをデーモン化するか．受信部スレッド終了を待たずにメインスレッドを停止させる．
        """
        if(ipaddr is None):
            host = socket.gethostname()
            ipaddr = socket.gethostbyname(host)
        self.ipaddr = ipaddr
        self.port = port
        self.set_daemon = set_daemon
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.loopflag = False
        print("ip:{0} port:{1}".format(self.ipaddr, self.port))
    
    def loop(self):
        self.sock.settimeout(0.5)
        self.sock.bind((self.ipaddr, self.port))
        self.sock.listen(1)
        print("start listening...")
        while(self.loopflag):
            try:
                conn, addr = self.sock.accept()
            except socket.timeout:
                continue
            print("accepted")
            with conn:
                while(self.loopflag):
                    # print("waiting...")
                    data = conn.recv(1024)
                    if(not data):
                        break
                    if(data[:1]==b"\x08"):  # 連続してbackspaceを押すと，複数個同時に送られてくる（例：b"\x08\x08\x08）ことがあるため，先頭8バイトのみチェック
                        print("> BS")
                        type_backspace()
                    elif(data==b"\x0a"):
                        print("> Enter")
                        type_enter()
                    elif(data==b"\x00"):
                        print("STOP CLIENT")
                        break
                    else:
                        text = data.decode("utf-8")
                        print(">", text)
                        type_text(text)

    def start_loop(self):
        self.loopflag = True
        self.thread = threading.Thread(target=self.loop)
        if(self.set_daemon):
            self.thread.setDaemon(True)
        self.thread.start()
        print("start_thread")
    
    def stop_loop(self):
        print("stop loop")
        self.loopflag = False
        time.sleep(0.6)  # socketがtimeoutするまで待つ
        if(not self.set_daemon):
            print("waiting to stop client...")  # 送信側が停止するのを待つ
            self.thread.join()
        print("stop_thread")
    
    def close_sock(self):
        self.sock.close()
        print("socket closed")

def main():
    # コマンドライン引数
    ipaddr = None
    args = sys.argv
    if(len(args)<=1):
        print("Usage: flick_kb_receiver [PORT] [IP (optional)]")
        sys.exit()
    elif(len(args)==2):
        port = int(args[1])
    else:
        port = int(args[1])
        ipaddr = args[2]

    # メイン処理
    receiver = Receiver(port=port, ipaddr=ipaddr)
    receiver.start_loop()
    while True:
        stopper = input()
        if(stopper=="s"):
            receiver.stop_loop()
            break
    receiver.close_sock()

if __name__=="__main__":
    main()
```
PC側です。文字入力にはpyautoguiを使いました。pyautoguiはキー操作やマウス操作等PC操作全般が可能なライブラリです。受信したバイナリデータはEnter、Backspace、終了コードでなければデコードしてテキスト入力処理へ回します。  

しかし、pyautoguiでは日本語入力ができないそうなので、`type_text`メソッドでテキストをクリップボードに保存してから貼り付ける、といった方法をとっています（[参考](http://sagantaf.hatenablog.com/entry/2017/10/18/231750)）。  

Backspaceについてですが、スマホ側にて連続で文字削除するとBackspaceコード`\x08`が`\x08\x08\x08`のように複数個同時に送られてくることがありました。そのため、`if(data[:1]==b"\x08"):`という書き方で、先頭の1バイトだけを見るようにしています。ちなみに`data[0]`だと`8`（10進数に変換された値）が返ってきます。  

通信部は別スレッドで動かし、メインスレッドはプログラム終了コマンドを待つようにしています。実行中、コンソールに`s`を入力しEnterを押すとプログラムを終了できます。しかし、通信がつながっている状態では通信部のスレッドが停止しないため、通信部をデーモン化しメインスレッド終了時に強制的に停止するようにしています。これが良い方法かは分かりません。



## 動作
### ファイル配置
[PC]

```
flick_kb_receiver.py
```

[iOS]

```
flick_keyboard（任意のフォルダ）
├── flick_kb.py （実行ファイル）
├── text_client.py
```



### 実行手順

#### 接続・入力

1. PC側でポート番号をコマンドライン引数として実行  
   例：`python flick_kb_receiver.py 50001`
2. iOS側のPytoアプリで`flick_kb.py`を開き、右下の歯車マークを押す。その中のArgumentsにポート番号とIPアドレスを入力後、実行  
   例：`50001 192.168.1.2`
3. PCで任意のテキストエディタや検索窓等にカーソルを合わせる
4. iOS側のテキストボックスに入力確定後、再度確定を押す
5. PC側にテキストが入力される


<img width="375" alt="12C402D0-A9C4-41A1-9B3A-AF6F0A083CF8.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549600/aa9099a4-efaf-061e-113a-5b341f356f94.jpeg">
PytoでArgumentsを設定する画面



#### 切断・終了

1. iOS側のプログラムを停止
2. PC側のコンソールに"s"を入力後Enterを押し、PC側のプログラムを停止


iOS側でimport部のエラーが出た場合は、フォルダアクセスが許可されていないと思うので、Pytoエディタ画面右下の南京錠マークから許可してください。  

PC側の初回起動時にファイアウォールのアクセス許可が出ますが、パブリックアクセスを許可しないとつながりませんでした。


### 動作の様子
![E8D546BF-13AA-411C-8FE0-097E427CB509.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549600/47e5881d-4fdd-a410-cdd9-6378f1b1b676.gif)

上のGIFはPCとiPhoneの画面を後から合わせたものです。接続からVSCodeへの文字入力、切断まで行っています。遅延はほとんど感じられませんでした。

## おわりに
小さいタッチパネルで日本語を入力するためのキーボードとして、フリック入力は非常に良い手法だと思います。今回、実際にPCでフリック入力を試してみて、PCキーボードが使えない人にフリック入力という選択肢が増えることでPCへの敷居が下がるのではないかと感じました。PC-スマホ間の視線移動も少なく、意外と違和感なく入力できました。

しかし、私はPCキーボードを使ったほうが速く打てるため、フリック入力を使うメリットはほとんどありません。片手で打てること、寝ながら使えることくらいでしょうか。やはりPCのキーボード入力は指すべてを生かすことができ、記号も打ちやすいので、使えるに越したことはないです。