[備忘録] Tkinter PropertyGrid を実装してみた

## はじめに

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/dd5497aa-bb0e-4710-8cda-b29d7fb4b348.png)


デスクトップアプリを作る時、設定画面で毎回同じような作業を繰り返していました。
ずいぶん昔のこと・・・。

- 設定項目が増えるたびに、ラベル＋Entry のコピペ作業
- bool値はCheckbutton、int値はSpinbox... 型ごとにウィジェット選択
- 設定の保存・読込を毎回一から実装

そこで Visual Studio のプロパティウィンドウのような **PropertyGrid** を Tkinter で実装してみました。型に応じて自動でUIを生成し、設定の永続化まで対応したクラスです。

## 要件

このツールは、設定値の型に応じてウィジェットを自動生成し、カテゴリ別に整理できます。設定はJSON形式で保存・読み込みができ、終了時の自動保存にも対応。標準ライブラリのみで動作し、1行で項目を追加できる手軽さと拡張性の高さが特徴です。

## 仕様

### 画面イメージ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/6ef8308f-3792-4d45-b67c-54f4620e2f93.png)

設定値を表示を押下するとコンソールに出力する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/5ab86959-364c-4394-9d2b-ccc94c051335.png)

設定を保存を押下するとフィアルダイアログが表示されてファイル名を指定して保存できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/8bc1bf3a-6484-4f80-8b1a-cc7faddebc03.png)

設定読込を押下するとファイルを指定してロードできる。


### 対応データ型とウィジェット
| データ型 | ウィジェット | 備考 |
|---------|-------------|------|
| `bool` | `Checkbutton` | True/False の切り替え |
| `int` | `Spinbox` | 数値入力（範囲指定可能） |
| `list` | `Combobox` | 選択肢から選択 |
| `str` | `Entry` | 自由テキスト入力 |

### ファイル形式
- **保存形式**: JSON（UTF-8エンコーディング）
- **ファイル拡張子**: `.json`
- **エラーハンドリング**: ファイル読み書き時の例外処理

## 設計

### クラス設計
```
PropertyGrid (ttk.Frame)
├── properties: dict[str, tk.Variable]  # プロパティ名と変数の対応
├── row_count: int                      # 現在の行数
└── メソッド群
    ├── add_category()     # カテゴリ追加
    ├── add_property()     # プロパティ追加
    ├── get_values()       # 全値取得
    ├── set_value()        # 値設定
    ├── save_to_file()     # ファイル保存
    ├── load_from_file()   # ファイル読込
    ├── auto_save()        # 自動保存
    └── auto_load()        # 自動読込
```

### 処理の流れ

```mermaid
flowchart TD
    A[PropertyGrid初期化] --> B[add_property呼び出し]
    B --> C{データ型判定}
    
    C -->|bool型| D[BooleanVar作成]
    C -->|int型| E[IntVar作成]
    C -->|list型| F[StringVar作成]
    C -->|str型| G[StringVar作成]
    
    D --> H[Checkbutton生成]
    E --> I[Spinbox生成]
    F --> J[Combobox生成]
    G --> K[Entry生成]
    
    H --> L[grid配置]
    I --> L
    J --> L
    K --> L
    
    L --> M[properties辞書に登録]
    M --> N[row_count++]
    N --> O{他の項目追加?}
    
    O -->|Yes| B
    O -->|No| P[UI完成]
    
    P --> Q{ユーザー操作}
    
    Q -->|値取得| R[get_values呼び出し]
    Q -->|値設定| S[set_value呼び出し]
    Q -->|保存| T[save_to_file呼び出し]
    Q -->|読込| U[load_from_file呼び出し]
    
    R --> V[全Variable値を辞書で返却]
    S --> W[指定Variable値を更新]
    
    T --> X{ファイル名指定?}
    X -->|No| Y[ダイアログ表示]
    X -->|Yes| Z[JSONファイル作成]
    Y --> Z
    Z --> AA{保存成功?}
    AA -->|Yes| BB[成功メッセージ表示]
    AA -->|No| CC[エラーメッセージ表示]
    
    U --> DD{ファイル名指定?}
    DD -->|No| EE[ダイアログ表示]
    DD -->|Yes| FF[JSONファイル読込]
    EE --> FF
    FF --> GG{読込成功?}
    GG -->|Yes| HH[各Variable値を更新]
    GG -->|No| II[エラーメッセージ表示]
    HH --> JJ[成功メッセージ表示]
    
    V --> Q
    W --> Q
    BB --> Q
    CC --> Q
    JJ --> Q
    II --> Q
    
    style A fill:#e1f5fe
    style P fill:#e8f5e8
    style C fill:#fff3e0
    style X fill:#fff3e0
    style DD fill:#fff3e0
    style AA fill:#ffebee
    style GG fill:#ffebee
```

### レイアウト設計
- **grid()** レイアウトで表形式を実現
- **左列**: プロパティ名（固定幅）
- **右列**: 値編集ウィジェット（可変幅）
- **ヘッダー**: プロパティ/値の列見出し

## 実装

### ソースコード

動作確認：Python 3.13.3／Windows 11

```python
import tkinter as tk
from tkinter import ttk
from tkinter import filedialog, messagebox
import json
import os

class PropertyGrid(ttk.Frame):
    def __init__(self, parent, **kwargs):
        super().__init__(parent, **kwargs)
        self.properties = {}
        self.row_count = 0
        
        # ヘッダー（オプション）
        ttk.Label(self, text="プロパティ", font=('', 9, 'bold')).grid(row=0, column=0, sticky="w", padx=5, pady=2)
        ttk.Label(self, text="値", font=('', 9, 'bold')).grid(row=0, column=1, sticky="w", padx=5, pady=2)
        
        # 区切り線
        ttk.Separator(self, orient='horizontal').grid(row=1, column=0, columnspan=2, sticky="ew", pady=2)
        self.row_count = 2
        
        # 列の重みを設定（右の列が伸縮する）
        self.columnconfigure(1, weight=1)
    
    def add_category(self, category_name):
        """カテゴリヘッダーを追加"""
        category_frame = ttk.Frame(self)
        category_frame.grid(row=self.row_count, column=0, columnspan=2, sticky="ew", pady=(10, 2))
        
        ttk.Label(category_frame, text=f"▼ {category_name}", 
                 font=('', 9, 'bold'), foreground='blue').pack(side="left")
        ttk.Separator(category_frame, orient='horizontal').pack(side="right", fill="x", expand=True, padx=(10, 0))
        
        self.row_count += 1
    
    def add_property(self, name, value):
        """プロパティを追加"""
        # 既存のプロパティがある場合は削除（メモリリーク対策）
        if name in self.properties:
            print(f"警告: プロパティ '{name}' は既に存在します。上書きします。")
            # 古いウィジェットの参照を削除
            del self.properties[name]
        
        # ラベル（左列）
        label = ttk.Label(self, text=name)
        label.grid(row=self.row_count, column=0, sticky="w", padx=5, pady=2)
        
        # 値に応じてウィジェットを作成（右列）
        if isinstance(value, bool):
            var = tk.BooleanVar(value=value)
            widget = ttk.Checkbutton(self, variable=var)
        elif isinstance(value, int):
            var = tk.IntVar(value=value)
            widget = ttk.Spinbox(self, from_=0, to=9999, textvariable=var, width=20)
        elif isinstance(value, list):
            var = tk.StringVar(value=value[0] if value else "")
            widget = ttk.Combobox(self, textvariable=var, values=value, state="readonly", width=18)
        else:
            var = tk.StringVar(value=str(value))
            widget = ttk.Entry(self, textvariable=var, width=20)
        
        widget.grid(row=self.row_count, column=1, sticky="ew", padx=5, pady=2)
        
        # プロパティを保存
        self.properties[name] = var
        self.row_count += 1
    
    def get_values(self):
        """全プロパティの値を辞書で取得"""
        return {name: var.get() for name, var in self.properties.items()}
    
    def set_value(self, name, value):
        """特定のプロパティの値を設定"""
        if name in self.properties:
            self.properties[name].set(value)
    
    def save_to_file(self, filename=None):
        """設定をJSONファイルに保存"""
        if filename is None:
            filename = filedialog.asksaveasfilename(
                title="設定を保存",
                defaultextension=".json",
                filetypes=[("JSON files", "*.json"), ("All files", "*.*")]
            )
        
        if filename:
            try:
                values = self.get_values()
                with open(filename, 'w', encoding='utf-8') as f:
                    json.dump(values, f, ensure_ascii=False, indent=2)
                messagebox.showinfo("保存完了", f"設定を保存しました:\n{filename}")
                return True
            except (PermissionError, OSError) as e:
                messagebox.showerror("保存エラー", f"ファイルの保存に失敗しました:\n{str(e)}")
                return False
            except Exception as e:
                messagebox.showerror("保存エラー", f"設定の保存に失敗しました:\n{str(e)}")
                return False
        return False
    
    def load_from_file(self, filename=None):
        """JSONファイルから設定を読込"""
        if filename is None:
            filename = filedialog.askopenfilename(
                title="設定を読込",
                filetypes=[("JSON files", "*.json"), ("All files", "*.*")]
            )
        
        if filename and os.path.exists(filename):
            try:
                with open(filename, 'r', encoding='utf-8') as f:
                    values = json.load(f)
                
                # 読み込んだ値を設定（型キャスト付き）
                for name, value in values.items():
                    var = self.properties.get(name)
                    if var is None:
                        continue
                    
                    try:
                        # 型に応じてキャスト
                        if isinstance(var, tk.IntVar):
                            value = int(value)
                        elif isinstance(var, tk.BooleanVar):
                            if isinstance(value, str):
                                value = value.lower() == 'true'
                            else:
                                value = bool(value)
                        elif isinstance(var, tk.StringVar):
                            value = str(value)
                        
                        var.set(value)
                    except (ValueError, TypeError) as e:
                        print(f"警告: プロパティ '{name}' の値 '{value}' を設定できませんでした: {e}")
                        continue
                
                messagebox.showinfo("読込完了", f"設定を読み込みました:\n{filename}")
                return True
            except (json.JSONDecodeError, UnicodeDecodeError) as e:
                messagebox.showerror("読込エラー", f"ファイルの読み込みに失敗しました:\n{str(e)}")
                return False
            except (FileNotFoundError, PermissionError) as e:
                messagebox.showerror("読込エラー", f"ファイルにアクセスできませんでした:\n{str(e)}")
                return False
            except Exception as e:
                messagebox.showerror("読込エラー", f"設定の読み込みに失敗しました:\n{str(e)}")
                return False
        return False
    
    def auto_save(self, filename="settings.json"):
        """自動保存（ファイルダイアログなし）"""
        try:
            values = self.get_values()
            with open(filename, 'w', encoding='utf-8') as f:
                json.dump(values, f, ensure_ascii=False, indent=2)
            return True
        except (PermissionError, OSError) as e:
            print(f"自動保存エラー: {e}")
            return False
        except Exception as e:
            print(f"予期しない自動保存エラー: {e}")
            return False
    
    def auto_load(self, filename="settings.json"):
        """自動読込（ファイルダイアログなし）"""
        if os.path.exists(filename):
            try:
                with open(filename, 'r', encoding='utf-8') as f:
                    values = json.load(f)
                
                # 読み込んだ値を設定（型キャスト付き）
                for name, value in values.items():
                    var = self.properties.get(name)
                    if var is None:
                        continue
                    
                    try:
                        # 型に応じてキャスト
                        if isinstance(var, tk.IntVar):
                            value = int(value)
                        elif isinstance(var, tk.BooleanVar):
                            if isinstance(value, str):
                                value = value.lower() == 'true'
                            else:
                                value = bool(value)
                        elif isinstance(var, tk.StringVar):
                            value = str(value)
                        
                        var.set(value)
                    except (ValueError, TypeError) as e:
                        print(f"警告: プロパティ '{name}' の値 '{value}' を設定できませんでした: {e}")
                        continue
                
                return True
            except (json.JSONDecodeError, UnicodeDecodeError) as e:
                print(f"自動読込エラー: {e}")
                return False
            except (FileNotFoundError, PermissionError) as e:
                print(f"ファイルアクセスエラー: {e}")
                return False
            except Exception as e:
                print(f"予期しない自動読込エラー: {e}")
                return False
        return False

# 使用例
if __name__ == "__main__":
    root = tk.Tk()
    root.title("PropertyGrid デモ")
    root.geometry("400x500")
    
    # メインフレーム
    main_frame = ttk.Frame(root, padding=10)
    main_frame.pack(fill="both", expand=True)
    
    # PropertyGrid作成
    grid = PropertyGrid(main_frame)
    grid.pack(fill="both", expand=True)
    
    # カテゴリとプロパティを追加
    grid.add_category("基本設定")
    grid.add_property("ユーザー名", "Qiitan")
    grid.add_property("フルスクリーン", True)
    grid.add_property("自動保存", False)
    
    grid.add_category("表示設定")
    grid.add_property("解像度", ["1920×1080", "1680×1050", "1280×720", "1024×768"])
    grid.add_property("テーマ", ["ダーク", "ライト", "オート"])
    grid.add_property("フォントサイズ", 12)
    
    grid.add_category("高度な設定")
    grid.add_property("デバッグモード", False)
    grid.add_property("ログレベル", ["INFO", "DEBUG", "WARNING", "ERROR"])
    grid.add_property("接続タイムアウト", 30)
    
    # --- プロパティ追加ここまで ---
    # 修正: すべてのプロパティ追加後に自動読込を実行
    grid.auto_load("my_settings.json")
    
    # ボタンフレーム
    button_frame = ttk.Frame(main_frame)
    button_frame.pack(fill="x", pady=(10, 0))
    
    def show_values():
        values = grid.get_values()
        print("現在の設定値:")
        for key, value in values.items():
            print(f"  {key}: {value}")
    
    def reset_defaults():
        grid.set_value("ユーザー名", "デフォルトユーザー")
        grid.set_value("フルスクリーン", False)
        grid.set_value("フォントサイズ", 10)
    
    def save_settings():
        grid.save_to_file()
    
    def load_settings():
        grid.load_from_file()
    
    def auto_save_settings():
        if grid.auto_save("my_settings.json"):
            messagebox.showinfo("自動保存", "設定を自動保存しました")
    
    # 終了時の自動保存を設定
    def on_closing():
        try:
            grid.auto_save("my_settings.json")  # 終了時に自動保存
        except Exception as e:
            print(f"終了時の保存でエラーが発生しましたが、アプリを終了します: {e}")
        finally:
            root.destroy()
    
    root.protocol("WM_DELETE_WINDOW", on_closing)
    
    ttk.Button(button_frame, text="設定値を表示", command=show_values).pack(side="left", padx=(0, 5))
    ttk.Button(button_frame, text="設定を保存", command=save_settings).pack(side="left", padx=(0, 5))
    ttk.Button(button_frame, text="設定を読込", command=load_settings).pack(side="left", padx=(0, 5))
    ttk.Button(button_frame, text="自動保存", command=auto_save_settings).pack(side="left", padx=(0, 5))
    ttk.Button(button_frame, text="デフォルトに戻す", command=reset_defaults).pack(side="left")
    
    root.mainloop()
```

## 使用方法

### 基本的な使い方
```python
# PropertyGrid作成
grid = PropertyGrid(parent_frame)

# プロパティ追加（型は自動判別）
grid.add_property("文字列項目", "初期値")
grid.add_property("真偽値項目", True)
grid.add_property("数値項目", 42)
grid.add_property("選択項目", ["選択肢1", "選択肢2", "選択肢3"])

# カテゴリでグループ分け
grid.add_category("基本設定")
grid.add_property("ユーザー名", "user")
```

### 設定の取得・設定
```python
# 全設定値を辞書で取得
values = grid.get_values()
print(values)  # {'ユーザー名': 'user', 'フルスクリーン': True, ...}

# 特定の値を設定
grid.set_value("ユーザー名", "新しいユーザー")
```

### ファイル保存・読込
```python
# ファイルダイアログで保存
grid.save_to_file()

# ファイルダイアログで読込
grid.load_from_file()

# 指定ファイル名で自動保存・読込
grid.auto_save("config.json")
grid.auto_load("config.json")
```

## 生成されるJSONの例
```json
{
  "ユーザー名": "Qiitan",
  "フルスクリーン": true,
  "自動保存": false,
  "解像度": "1920×1080",
  "テーマ": "ダーク",
  "フォントサイズ": 12,
  "デバッグモード": false,
  "ログレベル": "INFO",
  "接続タイムアウト": 30
}
```

## 実装のポイント

### 1. 型判定によるウィジェット自動選択
値の型を見て適切なウィジェットを選択するのがキモです。
```python
if isinstance(value, bool):
    var = tk.BooleanVar(value=value)
    widget = ttk.Checkbutton(self, variable=var)
elif isinstance(value, int):
    var = tk.IntVar(value=value)
    widget = ttk.Spinbox(self, from_=0, to=9999, textvariable=var)
# ...
```

### 2. grid()レイアウトによる表形式UI
pack()だと崩れやすいので、grid()で行列を管理しました。
```python
# ラベル（左列）
label.grid(row=self.row_count, column=0, sticky="w", padx=5, pady=2)

# ウィジェット（右列）
widget.grid(row=self.row_count, column=1, sticky="ew", padx=5, pady=2)
```

### 3. 辞書による設定管理
プロパティ名と変数を辞書で管理することで、値の取得・設定を簡単にしました。
```python
self.properties[name] = var  # プロパティ名と変数を紐付け

def get_values(self):
    return {name: var.get() for name, var in self.properties.items()}
```

## 今後の拡張案

### カスタムウィジェット追加
日付や色選択など、他の型も対応できそうです。
```python
elif isinstance(value, datetime.date):
    # 日付選択ウィジェット
    var = tk.StringVar(value=value.strftime("%Y-%m-%d"))
    widget = DateEntry(self, textvariable=var)
```

### バリデーション機能
入力値の検証機能があると便利かもしれません。
```python
def add_property(self, name, value, validator=None):
    # validator関数で入力値を検証
    if validator and not validator(value):
        raise ValueError(f"Invalid value for {name}")
```

### 設定変更の監視
リアルタイムで設定変更を検知できると面白そうです。
```python
def add_property(self, name, value, callback=None):
    var.trace_add("write", lambda *args: callback(name, var.get()))
```

## まとめ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/d303abba-9db7-421d-b30b-41232d41ba90.png)

設定項目を1行で追加できる手軽さや、型に応じて適切なUIが自動で選べる安心感は大きなメリットです。また、設定内容をJSONで保存できるため、データの永続化もスムーズに行えます。

同じような悩みを持つ方の参考になれば幸いです。

## 参考リンク
- [Tkinter公式ドキュメント](https://docs.python.org/ja/3/library/tkinter.html)
- [ttk Widget Set](https://docs.python.org/ja/3/library/tkinter.ttk.html)