`tkinter`の`Entry`にバリデーションを設定する　アップデート版

# 要約
以前に書いた`tkinter`にバリデーションを設定する記事のコードを
- 型アノテーションを使う
- `Entry`を継承したバリデーター付き`Entry`を作るのではなく、バリデーションを設定する関数を実装する

ように書き直してみた。
バリデーションを登録するための[`register`メソッド](https://qiita.com/junkmd/items/1623025afb56a8731f8d#register%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89)、[`vcmd`オプション](https://qiita.com/junkmd/items/1623025afb56a8731f8d#validatecommand%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3)、[`validate`オプション](https://qiita.com/junkmd/items/1623025afb56a8731f8d#validate%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3)については変化がないので下記記事を参照。

https://qiita.com/junkmd/items/1623025afb56a8731f8d

# コード全容
https://github.com/junkmd/tk_widget_utils

# コード概要
## 列挙体
`enum`ライブラリを使い、定数は列挙体にした。
ちなみに`str`と`Enum`を多重継承すると、([`IntEnum`が`.value`を参照しなくても`int`と値の比較が可能なように]((https://docs.python.org/ja/3/library/enum.html#others)))文字列型と`.value`を使わなくても比較が可能になる。

```python:tk_widget_utils/entries.py
class _StrEnum(str, Enum):
    """Enum where members are also (and must be) strs"""

    pass


class ActionType(IntEnum):
    INSERT = 1
    DELETE = 0
    FOCUS_OR_FORCE = -1


class TriggerType(_StrEnum):
    FOCUS_IN = "focusin"
    FOCUS_OUT = "focusout"
    FORCED = "forced"
    KEY = "key"


class ValidationType(_StrEnum):
    ALL = "all"
    FOCUS = "focus"
    FOCUS_IN = "focusin"
    FOCUS_OUT = "focusout"
    FORCED = "forced"
    KEY = "key"
    NONE = "none"
```

## `validatecommand`で指定した関数に渡されるパラメータのデータクラス
[`dataclasses`ライブラリ](https://docs.python.org/ja/3/library/dataclasses.html)を使い、コンストラクタで渡された各パラメータを属性にする部分を省略した。
[`tk`関数のパラーメータ名そのまま](https://www.tcl.tk/man/tcl8.4/TkCmd/entry.htm#M14)だと参照するときマジックナンバー的でわかりにくいので、エイリアス的にプロパティを設定しアクセスがしやすいようにした。

```python:tk_widget_utils/entries.py
@dataclass(frozen=True)
class VcmdParams:
    """Dataclass for tkinter entry validate command params."""

    d: str  # action_type
    i: str  # index
    P: str  # text_allowed_to_edit
    s: str  # text_prior_to_edit
    S: str  # text_being_changed
    v: str  # validation_type
    V: str  # trigger_type
    W: str  # widget_name

    @property
    def action_type(self) -> ActionType:
        """Type of action:
        1 for insert,
        0 for delete,
        or -1 for focus, forced or textvariable validation.
        """
        return ActionType(int(self.d))

    @property
    def index(self) -> int:
        """Index of char string to be inserted/deleted,
        if any, otherwise -1.
        """
        return int(self.i)

    @property
    def text_allowed_to_edit(self) -> str:
        """The value of the entry if the edit is allowed.
        If you are configuring the entry widget to have a new textvariable,
        this will be the value of that textvariable.
        """
        return self.P

    @property
    def text_prior_to_edit(self) -> str:
        """The current value of entry prior to editing."""
        return self.s

    @property
    def text_being_changed(self) -> str:
        """The text string being inserted/deleted,
        if any, empty string otherwise.
        """
        return self.S

    @property
    def validation_type(self) -> ValidationType:
        """The type of validation currently set."""
        return ValidationType(self.v)

    @property
    def trigger_type(self) -> TriggerType:
        """The type of validation that triggered the callback
        (key, focusin, focusout, forced).
        """
        return TriggerType(self.V)

    @property
    def widget_name(self) -> str:
        """The name of the entry widget."""
        return self.W

```

## バリデーション設定関数
以前の記事では、バリデーション用のメソッドをサブクラスでオーバーライドするような`Entry`の派生クラスを実装していた。
しかし考えてみれば
- `configure`で設定できるのにわざわざ`Entry`自身がバリデーション用のメソッドを持つ必要がない
- 様々なウィジェットを組み合わせた「メガウィジェット」を`Entry`のサブクラス込みで作ってしまうと、バリデーションの変更をしようとしたときに構成するウィジェットのクラスごと変更しなければならない

ことにも気づいた。

ロジックとしては以前のように、`configure`で`vcmd`と`validate`のオプションが渡せればいい。
バリデーションによっては`Entry`の内容を変更したいときがある(整数しか入力を許さないときに空文字になるなら代わりに0を挿入するetc...)ので、バリデーションが発火した際に呼び出される関数はウィジェットもとるようにした。


```python:tk_widget_utils/entries.py
_TkEntryType = TypeVar("_TkEntryType", bound=tk.Entry)


def set_vcmd(
    widget: _TkEntryType,
    func: Callable[[_TkEntryType, VcmdParams], bool],
    type_: Literal[
        "none", "focus", "focusin", "focusout", "key", "all"
    ] = ValidationType.ALL.value,
) -> _TkEntryType:
    """Configures validate command to entry widget.

    Returns widget that validate command configured.
    """

    @ft.wraps(func)
    def _func(d, i, P, s, S, v, V, W) -> bool:
        return func(widget, VcmdParams(d, i, P, s, S, v, V, W))

    vcmd = (widget.register(_func), *(f"%{a}" for a in "diPsSvVW"))
    widget.configure(validate=type_, vcmd=vcmd)
    return widget

```

# サンプルスクリプト
アルファベットと空文字しか許容しない`Entry`を作る場合。

```python:sample.py
import tkinter as tk

from tk_widget_utils import entries as wue


def main():
    root = tk.Tk()
    tk.Label(master=root, text="alpha").grid(column=0, row=0)
    ent = tk.Entry(master=root)

    def _validate(w: tk.Entry, vp: wue.VcmdParams) -> bool:
        if vp.text_allowed_to_edit.isalpha():
            return True
        if vp.text_allowed_to_edit == "":
            return True
        return False

    wue.set_vcmd(ent, _validate)
    ent.grid(column=1, row=0)
    root.mainloop()


if __name__ == "__main__":
    main()

```