tkinter.Treeview(python) に縦横スクロールバーとセル編集機能をつけてみた

# 概要

tkinter.Treeview(python) に縦横スクロールバーとセルの編集機能をつけるのはけっこう面倒なので、汎用的に使える部品を作ってみた。

# 外観

TreevewExのサンプルを実行したイメージ
![TreevewEx外観.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4076532/102eaf0c-a4bd-470d-afdf-e6a6be5a0685.png)


# リポジトリ
[GitHub公開ページ](https://github.com/fangface-hub/treeviewex)
`パッケージ化の勉強のため setup.py や test コードも含む。`

# 使用方法<br>`How to use`

1. パッケージをインストールする<br>`Install the package`
    treeviewex.py をそのまま使う場合は省略可。 <br>`If you use treeviewex.py as is, you can omit it.`

    ```
    pip install path/to/TreeviewEx.whl
    ```

2. サンプルコードを実行する<br>`Run the sample code`

    ```python
    from treeviewex import TreeviewEx
    from tkinter import Tk

    # 使用例(Usage Example)
    root = Tk()
    root.title("TreeviewEx Example")

    # TreeviewExを配置(Place TreeviewEx)
    treeview_ex = TreeviewEx(root)
    treeview_ex.grid(row=0, column=0, sticky="nsew")

    # Treeviewの列を設定(Set Treeview columns)
    columns = ("col1", "col2", "col3", "col4")
    treeview_ex["columns"] = columns
    treeview_ex.heading("#0", text="", anchor="w")
    treeview_ex.column("#0", width=0, stretch=False)
    for col in columns:
        treeview_ex.heading(col, text=f"{col.capitalize()}")
        # 各列の幅を手動で設定(Manually set the width of each column)
        treeview_ex.column(col, width=100)

    # テストデータを追加(Add test data)
    for i in range(100):
        treeview_ex.insert(
            "",
            "end",
            text="",
            values=(f"Value {i}A", f"Value {i}B", f"Value {i}C", f"Value {i}D"),
        )

    # readonlyの設定(Readonly settings)
    # 行をreadonlyに設定(Set a row to readonly)
    treeview_ex.set_readonly_row(row_id="I002", readonly=True)
    # 列をreadonlyに設定(Set a column to readonly)
    treeview_ex.set_readonly_column(column_id="#2", readonly=True)
    treeview_ex.set_readonly_cell(
        cell_id_pair=("I003", "#3"),
        readonly=True
    )  # セルをreadonlyに設定(Set a cell to readonly)

    # Frameの行と列の重みを設定して、レイアウトを調整
    # (Adjust the layout by setting row and column
    #  weights for the frame)
    root.grid_rowconfigure(0, weight=1)
    root.grid_columnconfigure(0, weight=1)

    root.mainloop()
    ```

---

# 公開インターフェース<br>`Public Interfaces`

以下は `TreeviewEx` クラスの主な公開インターフェースです。<br>`The following are the main public interfaces of the TreeviewEx class.`

### `set_readonly_row(row_id: str, readonly: bool = True) -> None`
指定した行を編集不可（readonly）に設定します。<br>`Set the specified row to readonly.`

- **Parameters**
  - `row_id` (`str`): 編集不可にする行の ID。<br>`The ID of the row to set as readonly.`
  - `readonly` (`bool`, optional): `True` の場合は編集不可、`False` の場合は編集可能に設定します。デフォルトは `True`。<br>`Set to True to make the row readonly, or False to make it editable. Default is True.`

- **Example**
    ```python
    treeview_ex.set_readonly_row(row_id="I002", readonly=True)
    ```

---

### `set_readonly_column(column_id: str, readonly: bool = True) -> None`
指定した列を編集不可（readonly）に設定します。<br>`Set the specified column to readonly.`

- **Parameters**
  - `column_id` (`str`): 編集不可にする列の ID。<br>`The ID of the column to set as readonly.`
  - `readonly` (`bool`, optional): `True` の場合は編集不可、`False` の場合は編集可能に設定します。デフォルトは `True`。<br>`Set to True to make the column readonly, or False to make it editable. Default is True.`

- **Example**
    ```python
    treeview_ex.set_readonly_column(column_id="#2", readonly=True)
    ```

---

### `set_readonly_cell(cell_id_pair: tuple, readonly: bool = True) -> None`
指定したセルを編集不可（readonly）に設定します。<br>`Set the specified cell to readonly.`

- **Parameters**
  - `cell_id_pair` (`tuple`): 編集不可にするセルの (行ID, 列ID) のペア。<br>`A tuple (row ID, column ID) of the cell to set as readonly.`
  - `readonly` (`bool`, optional): `True` の場合は編集不可、`False` の場合は編集可能に設定します。デフォルトは `True`。<br>`Set to True to make the cell readonly, or False to make it editable. Default is True.`

- **Example**
    ```python
    treeview_ex.set_readonly_cell(cell_id_pair=("I003", "#3"), readonly=True)
    ```


# ソースコード

[treeviewex.py](https://github.com/fangface-hub/treeviewex/blob/main/treeviewex.py)

```python
# python3
"""Treeview拡張版."""
from typing import Callable
from tkinter import (
    Frame,
    Entry,
    VERTICAL,
    HORIZONTAL,
    Event,
)
from tkinter.ttk import Treeview, Scrollbar


def _colid2colindex(column_id: str) -> int:
    """
    列IDを列インデックスに変換する.

    Parameters
    ----------
    column_id : str
        列ID.

    Returns
    -------
    int
        列インデックス.

    """
    return int(column_id[1:]) - 1


class TreeviewEx(Treeview):  # pylint: disable=too-many-ancestors
    """拡張TreeView."""

    def __init__(self, master=None, **kwargs):
        """
        コンストラクタ.

        Parameters
        ----------
        master : マスター, optional
            DESCRIPTION. The default is None.
        **kwargs : TYPE
            引数.

        Returns
        -------
        None.

        """
        # 初期化
        self.readonly_rows = set()  # 編集不可の行IDを保持
        self.readonly_columns = set()  # 編集不可の列IDを保持
        self.readonly_cells = set()  # 編集不可のセル (行ID, 列ID) を保持

        # その他の初期化処理
        self.frame = Frame(master=master)
        super().__init__(self.frame, **kwargs)

        # Entry ウィジェットをメンバとして作成
        self.entry = Entry(self)
        self.entry.bind("<Return>", self._on_return)
        self.entry.bind("<FocusOut>", self._on_focus_out)
        self.entry.bind("<Escape>", self._on_escape)

        # 縦方向スクロールバーを作成し、Canvasに接続
        self.scrollbar_y = Scrollbar(
            self.frame, orient=VERTICAL, command=self._on_scroll_y
        )
        self.configure(yscrollcommand=self.scrollbar_y.set)

        # 横方向スクロールバーを作成し、Canvasに接続
        self.scrollbar_x = Scrollbar(
            self.frame, orient=HORIZONTAL, command=self._on_scroll_x
        )
        self.configure(xscrollcommand=self.scrollbar_x.set)

        super().grid(row=0, column=0, sticky="nsew")
        self.scrollbar_y.grid(row=0, column=1, sticky="ns")
        self.scrollbar_x.grid(row=1, column=0, sticky="ew")

        # Frameの行と列の重みを設定して、レイアウトを調整
        self.frame.grid_rowconfigure(0, weight=1)
        self.frame.grid_columnconfigure(0, weight=1)

        # <Double-1> イベントに対する追加の振る舞いをバインド
        self._additional_bind_double_click()

        # マウスホイールイベントをバインド
        self.bind("<MouseWheel>", self._on_mouse_wheel)

        # 編集中のセル情報を保持するための変数
        self._editing_cell = None

    def _on_scroll_y(self, *args):
        """
        縦スクロール時の処理.

        Parameters
        ----------
        *args : tuple
            スクロールバーの引数.

        Returns
        -------
        None.

        """
        if self._editing_cell:
            self.cancel_edit()
        self.yview(*args)

    def _on_scroll_x(self, *args):
        """
        横スクロール時の処理.

        Parameters
        ----------
        *args : tuple
            スクロールバーの引数.

        Returns
        -------
        None.

        """
        if self._editing_cell:
            self.cancel_edit()
        self.xview(*args)

    def _on_mouse_wheel(self, event):
        """
        マウスホイール操作時の処理.

        Parameters
        ----------
        event : Event
            マウスホイールイベント.

        Returns
        -------
        None.

        """
        if self._editing_cell:
            self.cancel_edit()

        # 縦スクロールを実行
        self.yview_scroll(-1 * (event.delta // 120), "units")

    def _additional_bind_double_click(self):
        """
        ダブルクリックハンドラの追加.

        Returns
        -------
        None.

        """
        # 既存の <Double-1> バインドを取得
        original_handler = super().bind("<Double-1>")
        self._original_bind_double_click = original_handler

        # メンバ関数をバインド
        super().bind("<Double-1>", self._combined_handler)

    def _combined_handler(self, event: Event):
        """
        ダブルクリック時のハンドラ.

        Parameters
        ----------
        event : Event
            イベント.

        Returns
        -------
        None.

        """
        self.on_double_click(event)  # 追加の振る舞い
        if self._original_bind_double_click:
            self._original_bind_double_click(event)  # 元の振る舞い

    def bind(
        self, sequence: str = None, func: Callable = None, add: bool = None
    ) -> str:
        """
        bindのオーバーライド.

        Parameters
        ----------
        sequence : str, optional
            Treeview.bind()のsequence引数同様. The default is None.
        func : Callable, optional
            Treeview.bind()のfunc引数同様. The default is None.
        add : bool, optional
            Treeview.bind()のadd引数同様. The default is None.

        Returns
        -------
        str
            Treeview.bind()のreturn同様.

        """
        if sequence == "<Double-1>":

            def combined_handler(event):
                self.on_double_click(event)
                if func:
                    func(event)

            return super().bind(sequence, combined_handler, add=add)
        else:
            return super().bind(sequence, func, add=add)

    def pack(self, **kwargs):
        """
        packのオーバーライド.

        Parameters
        ----------
        **kwargs : dict
            pcak引数の辞書.

        Returns
        -------
        None.

        """
        self.frame.pack(**kwargs)

    def grid(self, **kwargs):
        """
        gridのオーバーライド.

        Parameters
        ----------
        **kwargs : dict
            grid引数の辞書.

        Returns
        -------
        None.

        """
        self.frame.grid(**kwargs)

    def column(self, column: str, option=None, **kw):
        """
        列生成のオーバーライド.

        Parameters
        ----------
        column : str
            列ID.
        option : str, optional
            オプション. The default is None.
        **kw : dict
            キーワード辞書.

        Returns
        -------
        TYPE
            DESCRIPTION.

        """
        if option is None and "stretch" not in kw:
            kw["stretch"] = False
        return super().column(column, option, **kw)

    def get_clicked_cell_id_pair(self, event: Event) -> tuple:
        """
        クリック位置のセルのID取得.

        Parameters
        ----------
        event : Event
            イベント.

        Returns
        -------
        cell_id_pair: tuple
            (行ID,列ID)のペア.

        """
        cell_id_pair = ("", "")
        region = self.identify_region(event.x, event.y)
        if region != "cell":
            return ("", "")
        cell_id_pair = (
            self.identify_row(event.y),
            self.identify_column(event.x),
        )
        return cell_id_pair

    def on_double_click(self, event: Event) -> None:
        """
        ダブルクリック.

        Parameters
        ----------
        event : Event
            イベント.

        Returns
        -------
        None.

        """
        self.start_edit(self.get_clicked_cell_id_pair(event))

    def get_cell_value(self, cell_id_pair: tuple) -> str:
        """
        セルの値取得.

        Parameters
        ----------
        cell_id_pair : tuple
            DESCRIPTION.

        Returns
        -------
        str
            DESCRIPTION.

        """
        (row_id, column_id) = cell_id_pair
        return self.item(row_id, "values")[_colid2colindex(column_id)]

    def start_edit(self, cell_id_pair: tuple) -> None:
        """セルの編集を開始."""
        if not self.is_valid_cell(cell_id_pair):
            raise ValueError(f"Invalid cell specified: {cell_id_pair}")

        row_id, column_id = cell_id_pair

        # readonly のチェック
        if (
            row_id in self.readonly_rows
            or column_id in self.readonly_columns
            or cell_id_pair in self.readonly_cells
        ):
            return  # 編集をスキップ

        # 編集処理を続行
        self._editing_cell = cell_id_pair
        cell_value = self.get_cell_value(cell_id_pair)

        # セルの位置とサイズを取得
        bbox = self.bbox(row_id, column_id)
        if not bbox:
            raise ValueError(
                f"Cannot determine the position of the cell: {cell_id_pair}"
            )

        x, y, width, height = bbox

        # Entry ウィジェットを設定
        self.entry.delete(0, "end")
        self.entry.insert(0, cell_value)
        self.entry.place(x=x, y=y, width=width, height=height)
        self.entry.focus_set()

    def is_valid_cell(self, cell_id_pair: tuple) -> bool:
        """
        セルの存在を確認する.

        Parameters
        ----------
        cell_id_pair : tuple
            (行ID, 列ID) のペア.

        Returns
        -------
        bool
            セルが有効であれば True, 無効であれば False.

        """
        row_id, column_id = cell_id_pair
        try:
            col_index = _colid2colindex(column_id)  # 列IDを列インデックスに変換
        except (ValueError, IndexError):  # pragma: no cover
            return False  # pragma: no cover

        if row_id not in self.get_children() or col_index >= len(
            self["columns"]
        ):
            return False

        return True

    def _on_return(self, event):  # pylint: disable=unused-argument
        """<Return> イベントハンドラー."""
        if self._editing_cell:
            self.update_cell(self._editing_cell, self.entry)

    def _on_focus_out(self, event):  # pylint: disable=unused-argument
        """<FocusOut> イベントハンドラー."""
        if self._editing_cell:
            self.update_cell(self._editing_cell, self.entry)

    def _on_escape(self, event):  # pylint: disable=unused-argument
        """<Escape> イベントハンドラー."""
        self.cancel_edit()

    def update_cell(self, cell_id_pair: tuple, entry: Entry) -> None:
        """セルの値を更新."""
        if not self.is_valid_cell(cell_id_pair):
            raise ValueError(f"Invalid cell specified: {cell_id_pair}")

        # 新しい値を取得
        new_value = entry.get()

        # 現在の値と異なる場合のみ更新
        if new_value != self.get_cell_value(cell_id_pair):
            values = list(self.item(cell_id_pair[0], "values"))
            col_index = _colid2colindex(cell_id_pair[1])
            values[col_index] = new_value
            self.item(cell_id_pair[0], values=values)

        self.cancel_edit()

    def cancel_edit(self):
        """
        編集中止.

        Returns
        -------
        None.

        """
        self.entry.place_forget()  # Entry を非表示にする
        self._editing_cell = None

    def set_readonly_row(self, row_id: str, readonly: bool = True) -> None:
        """行を readonly に設定."""
        if readonly:
            self.readonly_rows.add(row_id)
        else:
            self.readonly_rows.discard(row_id)

    def set_readonly_column(
        self, column_id: str, readonly: bool = True
    ) -> None:
        """列を readonly に設定."""
        if readonly:
            self.readonly_columns.add(column_id)
        else:
            self.readonly_columns.discard(column_id)

    def set_readonly_cell(
        self, cell_id_pair: tuple, readonly: bool = True
    ) -> None:
        """セルを readonly に設定."""
        if readonly:
            self.readonly_cells.add(cell_id_pair)
        else:
            self.readonly_cells.discard(cell_id_pair)

```