Alt + Tab が使いづらいのでカーソルキーで隣のウィンドウに切り替えるツールを Python + cx_Freeze でつくってみた

# はじめに
Alt + Tab よりもっと直感的に素早くウィンドウを切り替えたい、と常々思っていました。まだまだ荒削りですが、一応形になったので、今回勉強した内容をまとめてみようと思います。

# 本記事が含む内容
本記事は以下内容を含んでいます。

- ウィンドウ切り替えツールの紹介
- 実装に関する技術的な話
  - Python 3.6 で Windows アプリ（小さなツールですが）を作る一例
  - Windows API で指定ウィンドウをアクティブにする方法
  - Windows（特にWin10）におけるウィンドウ列挙方法
  - Python スクリプトを cx_Freeze で実行ファイル化する方法

上記について知りたい方は参考になるかもしれません。

# つくったもの
winhop というツール。Python 3.6 で書きました。GitHub に置きました。

- [ソース](https://github.com/stakiran/winhop)
- [バイナリ](https://github.com/stakiran/winhop/files/1727362/winhop.zip)

## 現時点での想定ユーザー
UI やドキュメントが手抜きなので玄人向けです。

- [AutoHotkey](https://autohotkey.com/) を使える技量がある
- ウィンドウハンドルやクラス名といった概念がわかり、かつ調査できる技量がある

## 現時点での使い心地
フリーソフトとしてだいぶ手抜きですが :sweat: 以下のような感じです。

- **非常駐ツールなので AutoHotkey 等から起動してください**
- README は知った気に書いたオレオレ英語だけです
- （特にWin10では）要らんウィンドウが切り替え対象になってしまうのがジャマなので、オプションを駆使して頑張って省いてください
- 作者環境では誤動作なくサクサク切り替えできていますが、他環境でどうなのかはわかりません
  - この手のソフト（ウィンドウを扱う系のソフト）は結構環境次第で動いたり動かなかったりする修羅の道だったりします :sweat:

鋭い方なら「タイトル詐欺じゃないか」と思われるかもしれませんが、はい、そのとおりです。ごめんなさい。厳密に言えば以下が正しいです。

- :x: Windowsキー + カーソルキーで隣のウィンドウに切り替えるツール
- :o: 隣のウィンドウに切り替える非常駐ツール（AutoHotkeyを使えば Windowsキー + カーソルキーをアサインできるよ♪）

# 技術的な話（道具編）

## 使った言語やライブラリ
- Python 3.6
- [cx_Freeze](https://anthony-tuininga.github.io/cx_Freeze/) …… 実行ファイル生成に使用
- [pywin32](https://github.com/mhammond/pywin32) …… Windows API ラッパー

# 技術的な話（ウィンドウ編）

## Q: どうやってウィンドウを切り替えている？
「別のウィンドウをアクティブにするにはどうするか」と言い換えることもできます。一番肝となるところです。

ソースで言うと [winhop.pyの103行目](https://github.com/stakiran/winhop/blob/792bf4c41f422e5bcbd8c445372478f52914048a/winhop.py#L103) あたり。簡単に書くと、WinAPI の AttachThreadInput → BringWindowToTop をした後に **当該ウィンドウの左上をクリックする** という流れです。クリックは邪道ですが、ほぼ確実にウィンドウをアクティブに出来るので頼ってしまいました。

他の Altab ソフトはどんなアルゴリズムで切り替えているんでしょうかね。

## Q: 切り替え対象ウィンドウはどうやって列挙している？
ソースでいえば [libwindow.pyの50行目](https://github.com/stakiran/winhop/blob/792bf4c41f422e5bcbd8c445372478f52914048a/libwindow.py#L50) あたり.

```python
def is_visible(hwnd):
    return win32gui.IsWindowVisible(hwnd)==1

def listup_windows():
    """ @return A list which contains of visible window handles. """
    hparent = 0
    hchild = 0
    queryclassname = None
    querycaption = None
    ret = []
    while True:
        hchild = win32gui.FindWindowEx(hparent, hchild, queryclassname, querycaption)

        # all parsed.
        if hchild==0:
            break

        if not(is_visible(hchild)):
            continue

        ret.append(hchild)

return ret
```

まず WinAPI の FindWindowEx 関数でズラズラ列挙しつつ、IsWindowVisible 関数で不可視ウィンドウを除外する……というのが基本戦略です。

ただ、これだけだと（特にWin10で）列挙されてほしくないゴミウィンドウがちらほら列挙されやがるので、利用者に指定して除外してもらうことにしました。コードでいうと [winhop.pyの19行目](https://github.com/stakiran/winhop/blob/792bf4c41f422e5bcbd8c445372478f52914048a/winhop.py#L19) あたり。文章で書くと、

- 「利用者が指定した文字列」をタイトル or クラス名に含むウィンドウを除外する
- 「利用者が指定した面積(ウィンドウ縦サイズと横サイズの積)」よりも小さい面積のウィンドウを除外する

こんな感じです。面積という概念は馴染み無いと思いますが、これで小さいウィンドウ（スタートボタンとかプログラムランチャとか言語バーとかサイズ 0x0 のよくわからんウィンドウとか）をごっそり省けるので何気に重宝しています。

### (余談)ゴミウィンドウ
私の Win10 環境だと、ゴミウィンドウとして以下がありました。

```
$ winlist --visible --area-over 25000 --format "'$caption' '$classname' $pos $size"
'Cortana' 'Windows.UI.Core.CoreWindow' (0,458) (392x582)
'タスク スケジューラ のジャンプ リスト' 'Windows.UI.Core.CoreWindow' (443,928) (256x112)
'スタート' 'Windows.UI.Core.CoreWindow' (0,458) (256x582)
'Windows シェル エクスペリエンス ホスト' 'Windows.UI.Core.CoreWindow' (0,0) (1920x1080)
'Microsoft Visual C++ Runtime Library' '#32770' (719,407) (496x290)
'' 'Shell_TrayWnd' (0,1040) (1920x40)
'' 'ApplicationFrameWindow' (1920,0) (1280x1024)
'' 'ApplicationFrameWindow' (0,0) (1920x1040)
```

Win7 ではそうでもなかったのに、Win10 になってから要らんウィンドウが多すぎるから困ったものです。

## Q: 「隣のウィンドウ」はどうやって判定している？
もう一つ、肝となる処理です。コードで言えば [winhop.pyの107行目](https://github.com/stakiran/winhop/blob/792bf4c41f422e5bcbd8c445372478f52914048a/winhop.py#L107) あたりですが、ぶっちゃけ泥臭く頑張ってます。

**ウィンドウの左上座標** に着目し、「アクティブウィンドウの左上座標(fore.xposとfore.ypos)」と「各ウィンドウの左上座標(hwnd.xposとhwnd.ypos)」の差を調べていって、最も小さいウィンドウを隣、とみなしています。ただし、方向が上下左右と四パターンあるので、差の計算方法も四通りに分岐します。

以下に、上方向に切り替える場合の遷移順を記してみました。

![winhop_next_window_algorithm.jpg](https://qiita-image-store.s3.amazonaws.com/0/191170/5f03f6f1-f295-e038-8c10-30c6a698b3cd.jpeg)

ちなみに、4から5に移るケースは Warp（例：上端ウィンドウがアクティブの時に、上に切り替えた場合、下端のウィンドウに切り替える) という言葉で扱っています。

# 技術的な話（実行ファイル生成編）
[cx_Freeze](https://anthony-tuininga.github.io/cx_Freeze/) を使って実行ファイルを生成してます。

以下、cx_Freeze をどのように使ったか等を雑多にまとめときます。

## なぜ cx_Freeze を選んだか
Python スクリプトを実行ファイル化する手段は複数ありますが、

- [PyInstaller](http://www.pyinstaller.org/) …… 昔使った時にビルドが重い、ファイル数もファイルサイズも大きすぎる、ということで見送った覚えがあります（今はどうなんでしょう）
- [py2exe](http://www.py2exe.org/) …… オプションは豊富ですが、 **Python 3.6 未対応** なのと 2008 年のver [0.6.9](https://sourceforge.net/projects/py2exe/files/py2exe/0.6.9/) 以降更新がないのが気になるので見送りました

というわけで cx_Freeze になりました。

### （余談）cx_Freeze の微妙な点
cx_Freeze ですが、微妙な点もあります。

- 依存ファイル数が多い＆オプションで一ファイル化できない
  - winhop みたいな小さいアプリでもファイル数199、フォルダ数20ほどあります
- 実行ファイル名を変えると動作しなくなる
- GUIアプリ生成モードでビルドした実行ファイルで、うっかり標準出力を行うとエラーが出て落ちる
  - 標準ライブラリ側で標準出力等を行っているケースがあり、その場合でも死にます

## ビルドスクリプト
[build.py](https://github.com/stakiran/winhop/blob/792bf4c41f422e5bcbd8c445372478f52914048a/build.py) に書いてます。

以下、詳しく見ていきます。

```python
name        = 'winhop'
version     = '1.0.0'
description = 'winhop'
base        = 'Win32GUI'
icon_path   = 'app.ico'
```

各種ソフトウェア情報を定義しているところです。

`base` というのは「実行ファイルのタイプ」を指定するもので、公式ドキュメントには説明が見当たりませんが、[cx_Freezeのソースによると](https://github.com/anthony-tuininga/cx_Freeze/blob/74f70312968fef7aad93d1a1a25906d12f5c22e5/cx_Freeze/setupwriter.py#L7) Console, Win32GUI, Win32Service の三つがあるみたいです。

- Console: たぶん DOS 窓で実行する用？
- Win32GUI: GUIアプリはこれ。エラーがあると Dialog を出す。
- Win32Service: よくわかりません

`icon_path` には実行ファイルのアイコン名を指定してます。

```
outdir   = '{:}'.format(name)
includes = []
excludes = []
packages = []
options = {
    'build_exe': {
        'build_exe': outdir,
        'includes' : includes,
        'excludes' : excludes,
        'packages' : packages,
    },
}
```

ビルドオプションのうち、モジュールの依存関係や除外指定を定義しています。が、cx_Freeze は基本的に自動で解決してくれるので特に触る必要はありません。

`build_exe` で生成先ディレクトリを指定するくらいでしょうか。（これをしないと `build\exe.win32-3.6` みたいな長ったらしいディレクトリ名で生成されてしまいます）

```python
entrypoint_filename = 'winhop.py'
entrypoint_fullpath = entrypoint_filename
executables = [
    Executable(entrypoint_fullpath, base=base, icon=icon_path)
]
```

エントリポイント(プログラム開始のMain)を指定しています。実行ファイル名は `エントリポイント名.exe` 固定になる（かつ **ファイル名を変えると動作しなくなる**） ので、ここで指定する Python ファイルの名前はよく練っておく必要があります。main.py とかだと main.exe になっちゃってわかりづらいです。なのでソフト名にするのが良いでしょう。

もうひとつ、 `entrypoint_fullpath` ですが、これはビルドスクリプトとエントリポイントのパスが違う場合は、`entrypoint_fullpath = os.path.join('src', entrypoint_filename)` みたいにして上手く見えるようにするためのものです。が、今回は全部同じディレクトリに置いてるのでいじっていません。

```python
setup(
    name        = name,
    version     = version,
    description = description,
    options     = options,
    executables = executables
)
```

最後に、上記で設定したパラメータ達をセットして setup を実行してやります。

## ビルドスクリプトのラッパー
いちいち `python build.py build` と叩くのはだるいので、ラッパーバッチファイルを書きました。

build.bat

```bat
@echo off
python "%~dp0build.py" build
copy "%~dp0readme.md" "%~dp0winhop"
exit /b
```

ここではビルド後、ビルドして出来た winhop フォルダに readme.md をコピーする、ということをしています。

他にも配布用処理が必要な場合は、ここにあれこれ書くことになるのだと思います。

## Q: Usage が見れないのはなぜ？
`winhop.exe -h` を実行すると、以下のようなエラーが出て Usage を見ることができません。

```
---------------------------
cx_Freeze: Python error in main script
---------------------------
Traceback (most recent call last):
  File "D:\bin1\python36\lib\site-packages\cx_Freeze\initscripts\__startup__.py", line 14, in run
  File "D:\bin1\python36\lib\site-packages\cx_Freeze\initscripts\Console.py", line 26, in run
  File "winhop.py", line 272, in <module>
  File "winhop.py", line 266, in parse_arguments
  File "D:\bin1\python36\lib\argparse.py", line 1730, in parse_args
  File "D:\bin1\python36\lib\argparse.py", line 1762, in parse_known_args
  File "D:\bin1\python36\lib\argparse.py", line 1968, in _parse_known_args
  File "D:\bin1\python36\lib\argparse.py", line 1908, in consume_optional
  File "D:\bin1\python36\lib\argparse.py", line 1836, in take_action
  File "D:\bin1\python36\lib\argparse.py", line 1020, in __call__
  File "D:\bin1\python36\lib\argparse.py", line 2362, in print_help
  File "D:\bin1\python36\lib\argparse.py", line 2368, in _print_message
AttributeError: 'NoneType' object has no attribute 'write'

---------------------------
OK   
---------------------------
```

これは、

- `-h` により標準出力に Usage を出そうとしている（引数ライブラリ argparse の働きです）
- しかし winhop.exe は GUI アプリ(`base = 'Win32GUI'`)なので、標準出力の口を持ってない
  - 標準出力に相当するオブジェクトが無くて `NoneType` になってます

ということだと思います。

ここはエラーを丸出しするのではなく、せめてこのダイアログ上に Usage を表示したいところです。というか、今気づきましたが、これ、**開発環境のパス(D:\bin1\python36)がモロに埋め込まれちゃってますね** :sweat: ……これはぜひ直すべきですね :sweat:

# おわりに
以上、荒削りで自己満感が強いですが、今回学びが多かったので思い切ってまとめてみました。何かの参考になれば幸いです。