ディスプレイ切替で毎回ウィンドウが大暴れ!? そのイライラを一発解消する方法！

![S__6570007.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/8efe588f-22f2-d53a-aa17-17dc4e880ee4.jpeg)



# はじめに

　現在の職場では、私は軽量なモバイルPCを持ち歩き、打ち合わせなどに参加しています。モバイルPCは持ち運びに便利な反面、ディスプレイが小さく、複数のウィンドウを開くと画面がすぐにいっぱいになり、ウィンドウが重なることも多々あります。

　会社ではこの点を配慮し、デスクには大きな外部ディスプレイを設置しています。そのため、デスクで作業するときには、2画面で作業ができ、効率も数倍向上するとの話を書籍で目にしたこともあり、とても快適に仕事を進められています。

　一方で、打ち合わせなどでモバイルPCを持ち歩く際には1画面に戻るため、ウィンドウの位置やサイズが毎回変わってしまいます。私は日常的に約20個のウィンドウを開いているため、再びデスクに戻った際はそれらをチマチマ元の位置に戻す作業が発生します。1日に10回程度画面の切り替えがあるので、1回あたり3分かかるこの作業により、1日で約30分、1か月で10時間、年間で120時間の無駄が生じている計算になります。

　この状況を改善すれば、年間に換算して一人月のコスト削減が見込まれます。そこで、情報システム担当として業務効率化を図るべく、こうした作業を自動化するツールを開発することを決意しました。


# 具体的な利用シーン

デスクで作業するときには、2つのディスプレイを使って、以下のように広々とした画面で作業をしています。
![display2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/eb392c13-2db9-1724-cf70-668a598c9668.jpeg)

しかし、打ち合わせでモバイルPCのみを持って移動する場合、表示は1画面に限られてしまい、以下のようになります。
![display1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/85862d82-819e-afa7-145a-297c297bd640.jpeg)

打ち合わせが終わりデスクに戻ってディスプレイを再接続しても、ウィンドウは元の位置に戻らず、すべて「画面1」に集まった状態です。そのため、ウィンドウを最適な場所に一つひとつ配置し直す必要があります。

このような煩雑な作業を解決するために、自動的にウィンドウを配置するプログラムを開発しようと考えています。



# プログラムの流れ

以下に、プログラムの内容と各関数の説明を記載します。

```python:
import sys
import time
import json
import os
import pygetwindow as gw
from screeninfo import get_monitors

# ===== 保存するためのファイル名 =====
CONFIG_FILE = 'window_positions.json'

# ===== ウィンドウ位置を保存 =====
def save_window_positions():
    positions = {}
    for window in gw.getAllTitles():
        try:
            win = gw.getWindowsWithTitle(window)
            if win and window:  # タイトルが空でないか確認
                positions[window] = {
                    'left': win[0].left,
                    'top': win[0].top,
                    'width': win[0].width,
                    'height': win[0].height
                }
        except Exception as e:
            print(f"ウィンドウの位置を取得できませんでした: {window}, エラー: {e}")
    with open(CONFIG_FILE, 'w') as f:
        json.dump(positions, f)
    print("ウィンドウ位置を保存しました。")


# ===== ウィンドウ位置を復元 =====
def restore_window_positions():
    if not os.path.exists(CONFIG_FILE):
        print("設定ファイルが見つかりません。")
        return

    with open(CONFIG_FILE, 'r') as f:
        positions = json.load(f)

    for title, pos in positions.items():
        win = gw.getWindowsWithTitle(title)
        if win:
            win[0].moveTo(pos['left'], pos['top'])
            win[0].resizeTo(pos['width'], pos['height'])
    print("ウィンドウ位置を復元しました。")


# ===== モニターの数を取得 =====
def get_monitor_count():
    return len(get_monitors())


# ===== ディスプレイの変更を検知 =====
def monitor_changes_listener():
    previous_monitor_count = get_monitor_count()
    while True:
        current_monitor_count = get_monitor_count()
        if current_monitor_count != previous_monitor_count:
            if current_monitor_count > previous_monitor_count:
                print("ディスプレイが再接続されました。ウィンドウ位置を復元します...")
                restore_window_positions()
            else:
                print("ディスプレイが切断されました。")
                #print("ディスプレイが切断されました。現在のウィンドウ位置を保存します...")
                #save_window_positions()
            previous_monitor_count = current_monitor_count
        time.sleep(2)

# ===== 実行 =====
if __name__ == "__main__":

    # コマンドライン引数のリストを取得
    args = sys.argv[1:]  # 0番目はスクリプト名なので1番目から
    
    if len(args) >= 1:

        # --- 監視サーバ起動 ---
        if args[0] == "server":
            save_window_positions()
            monitor_changes_listener()

        # --- 現在のウィンドウ位置を保存します ---
        elif args[0] == "save":
            save_window_positions()
        
        # --- ウィンドウ位置を復元します ---
        elif args[0] == "restore":
            restore_window_positions()
    else:
        print("引数がありません。")
```

## `save_window_positions`(ウィンドウ位置を保存)

この関数では、ディスプレイ上にある複数のウィンドウの「位置」や「大きさ」の情報を**JSON**形式で保存をします。

```python:
def save_window_positions():
    positions = {}
    for window in gw.getAllTitles():
        try:
            win = gw.getWindowsWithTitle(window)
            if win and window:  # タイトルが空でないか確認
                positions[window] = {
                    'left': win[0].left,
                    'top': win[0].top,
                    'width': win[0].width,
                    'height': win[0].height
                }
        except Exception as e:
            print(f"ウィンドウの位置を取得できませんでした: {window}, エラー: {e}")
    with open(CONFIG_FILE, 'w') as f:
        json.dump(positions, f)
    print("ウィンドウ位置を保存しました。")
```


## `restore_window_positions`(ウィンドウ位置を復元)

この関数は、`save_window_positions`によって保存された**JSON**形式のファイルを読み込み、ディスプレイ上で複数のウィンドウの「位置」や「サイズ」を復元します。

```python:
def restore_window_positions():
    if not os.path.exists(CONFIG_FILE):
        print("設定ファイルが見つかりません。")
        return

    with open(CONFIG_FILE, 'r') as f:
        positions = json.load(f)

    for title, pos in positions.items():
        win = gw.getWindowsWithTitle(title)
        if win:
            win[0].moveTo(pos['left'], pos['top'])
            win[0].resizeTo(pos['width'], pos['height'])
    print("ウィンドウ位置を復元しました。")
```

## `monitor_changes_listener`(ディスプレイの変更を検知)

この関数は、プログラムを常駐させて「待機状態」を維持します。2秒ごとにループを回してディスプレイ数に変化があるかを確認し、変化を検知した際に後続処理を実行します。

```python:
def monitor_changes_listener():
    previous_monitor_count = get_monitor_count()
    while True:
        current_monitor_count = get_monitor_count()
        if current_monitor_count != previous_monitor_count:
            if current_monitor_count > previous_monitor_count:
                print("ディスプレイが再接続されました。ウィンドウ位置を復元します...")
                restore_window_positions()
            else:
                print("ディスプレイが切断されました。現在のウィンドウ位置を保存します...")
                save_window_positions()
            previous_monitor_count = current_monitor_count
        time.sleep(2)
```

| ケース | 説明 |
|:-:|:-|
| ディスプレイ数が増えた場合 | **JSON**ファイルを基にウィンドウ位置を復元する |
| ディスプレイ数が減った場合 | ウィンドウ位置情報を**JSON**ファイルに保存する |




## `main`(メイン)

処理を実行する際は、引数を設定するようにします。

```python:
if __name__ == "__main__":

    # コマンドライン引数のリストを取得
    args = sys.argv[1:]  # 0番目はスクリプト名なので1番目から
    
    if len(args) >= 1:

        # --- 監視サーバ起動 ---
        if args[0] == "server":
            save_window_positions()
            monitor_changes_listener()

        # --- 現在のウィンドウ位置を保存します ---
        elif args[0] == "save":
            save_window_positions()
        
        # --- ウィンドウ位置を復元します ---
        elif args[0] == "restore":
            restore_window_positions()
    else:
        print("引数がありません。")
```
実行方法には、引数によって3つの選択肢があります。それぞれの詳細は以下の通りです。

| タイトル | 引数 | 説明 |
|:-:|:-:|:-|
| server  | 監視サーバの起動  | 監視サーバを起動して常駐させます。起動時にウィンドウ位置情報を保存し、ディスプレイ数の変化が検知された場合には、保存されたウィンドウ位置情報を読み込みます。 |
| save  | 現在のウィンドウ位置情報を保存 | コマンドを実行すると、現在のウィンドウ位置情報を**JSON**形式で保存し、復元可能な状態にします。 |
| restore  | ウィンドウ位置を復元  | 保存済みの**JSON**形式のウィンドウ位置情報を基に、ウィンドウ位置を復元します。 |

以下のように「コマンドプロンプト」から実行します。

```dosbatch
python app.py {引数}
```



# 実行結果
実行する前に、`pygetwindow`と`screeninfo`をインストールしてください。

```dosbatch
pip install pygetwindow
pip install screeninfo
```

以下に各引数による実行結果を示します。

## 現在のウィンドウ位置情報を保存 (save)

正しく`window_positions.json`が出力されました。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/d3f717bd-a872-3d93-1a30-59518bc713c7.png)

## ウィンドウ位置を復元 (restore)

こちらも成功です。「**現在のウィンドウ位置情報を保存 (save)**」で保存した位置にウィンドウが復元されました。

## 監視サーバ起動 (server)

この処理を実行したところ、ディスプレイ数の変化は検知できたものの、ウィンドウの自動移動はうまくいきませんでした。ディスプレイ数の変化が起きたときにウィンドウ位置情報が更新され、その後保存されてしまうため、意図した位置に復元できないことが分かりました。

現状のままでは、ディスプレイ数の変化による自動的な`save`と`restore`の実行は難しそうです。改善方法やアイデアがあれば、ぜひご教示いただけると幸いです。

# おわりに

今回、ウィンドウ位置情報を手動で保存 (`save`) し、復元 (`restore`) できるようになりました。これで、ウィンドウの位置を手動で調整する手間が省けます。

最後までお読みいただき、ありがとうございました。

# 追記(2024/11/06)
プログラムを何度が走らせると、`restore`の処理で不具合がありました。`restore`したはずなのに、ウィンドウがうんともすんとも言わないのです。良く調べてみると、このプログラムですと、「最小化」されたウィンドウには対応できないようです。
以下のように`restore_window_positions`関数を変更し、追加で`pywinauto`をインストールすれば、上手くいきました。

```dosbatch:
pip install pywinauto
```


```python:

# ===== ウィンドウ位置を復元 =====
def restore_window_positions():
    if not os.path.exists(CONFIG_FILE):
        print("設定ファイルが見つかりません。")
        return

    with open(CONFIG_FILE, 'r') as f:
        positions = json.load(f)

    for title, pos in positions.items():
        win = gw.getWindowsWithTitle(title)
        if win:
            # ウィンドウが最小化されている場合は復元
            if win[0].isMinimized:
                # pywinautoを使ってウィンドウを通常の状態に戻す
                app = Application().connect(title=title)
                app.window(title=title).restore()

            # 位置とサイズの復元
            win[0].moveTo(pos['left'], pos['top'])
            win[0].resizeTo(pos['width'], pos['height'])
    print("ウィンドウ位置を復元しました。")
```