CustomTkinterでSpinBoxを作成する

# まえがき
業務でCustomTkinterを使って簡単なデモアプリを作っているのですが、SpinBoxが用意されていないことが分かりました。
通常のTkinterでは用意されているのですが見た目が古臭い…。
ということでCustomTkinterを使ってイケてる(?)SpinBoxのWidgetを作りました。

本記事では

* CustomTkinter版SpinBoxの実装
* 実装の解説

の順で紹介します。

# CustomTkinter版SpinBoxの実装
## 動作仕様
* 任意の数値を入力できる
    *  設定された上限/下限の範囲外の数値は入力できず、上限/下限にクリップされる
* 上ボタンをクリックすると入力した数値を一定の数値で加算する
    * 設定された上限を超える場合は上限にクリップされる
* 下ボタンをクリックすると入力した数値を一定の数値で減算する
    * 設定された下限を下回る場合は下限にクリップされる
* 数値入力時、上ボタンクリック時、下ボタンクリック時に設定されたコールバック関数が呼び出される
    * 引数として更新後の数値が与えらえる

## 実装

```python:SpinBox.py
class SpinBox(ctk.CTkFrame):
    def __init__(self, master, width=100, increment=5, from_=0, to=100, initial_value=0, command=lambda v: None,font=None):
        super().__init__(master)

        self.current_value = initial_value
        self.from_ = float(from_)
        self.to = float(to)
        self.command = command

        self.entry = ctk.CTkEntry(self, width=width, font=font)
        self.entry.bind('<FocusOut>', self.update_value)
        self.entry.bind('<Return>', self.update_value)
        self.entry.insert(0, float(self.current_value))

        font = self.entry.cget("font")
        font_size = font[1] if type(font)==tuple else font.cget("size")
        self.up_button = ctk.CTkButton(self, text="▲", width=5, height=5, font=("Helvetica", int(font_size/2)), command=lambda: self.push_button(increment))
        self.down_button = ctk.CTkButton(self, text="▼", width=5, height=5, font=("Helvetica", int(font_size/2)), command=lambda: self.push_button(-increment))

        self.entry.grid(row=0, column=0, rowspan=2, sticky="nsew")
        self.up_button.grid(row=0, column=1, sticky="nsew")
        self.down_button.grid(row=1, column=1, sticky="nsew")

    def push_button(self, increment):
        current_value = self.current_value
        current_value += increment
        self.entry.delete(0, "end")
        self.entry.insert(0, current_value)

        self.update_value()

    def update_value(self, event=None):
        if self.validate_input():
            value = min(max(float(self.entry.get()), self.from_), self.to)
            self.current_value = value
            self.entry.delete(0, "end")
            self.entry.insert(0, self.current_value)
            self.command(self.current_value)
        else:
            self.entry.delete(0, 'end')
            self.entry.insert(0, self.current_value)

    def validate_input(self):
        value = self.entry.get()
        try:
            num = float(value)
            return True
        except ValueError:
            return False
```

動作確認用のコード
```python:main.py
import customtkinter as ctk
from SpinBox import SpinBox

main = ctk.CTk()
main.bind_all("<Button-1>", lambda e: e.widget.focus_set())

value = ctk.StringVar(main, value=10.0)
label = ctk.CTkLabel(main, textvariable=value)
label.pack()

spin_box = SpinBox(main, initial_value=10.0, command=lambda v: value.set(v))
spin_box.pack()

main.mainloop()
```

動作イメージ
![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3812550/26aded73-4838-02be-fdfd-49ae262c06f6.gif)


# 実装の解説
## EntryWidgetの作成
```python: SpinBox.py
        self.entry = ctk.CTkEntry(self, width=width, font=font)
        self.entry.bind('<FocusOut>', self.update_value)
        self.entry.bind('<Return>', self.update_value)
        self.entry.insert(0, float(initial_value))
```
`<FocusOut>`と`<Return>`のイベントをバインドすることで、数値を入力した後にSpinBoxからフォーカスを外した時、もしくはReturn押下時に入力されている数値を確定しコールバック関数を呼ぶようにしています。
ただし、これだけでは他のWidgetをクリックしたときにフォーカスが外れずコールバック関数が呼び出されません。それを解消するためにmain.pyで
```python: main.py
main.bind_all("<Button-1>", lambda e: e.widget.focus_set())
```
と設定することで、クリックしたときにクリックしたWidgetへフォーカスが移るようになりコールバック関数が呼ばれるようになります。

この時呼ばれる関数は以下です。
```python SpinBox.py
    def update_value(self, event=None):
        if self.validate_input():
            value = min(max(float(self.entry.get()), self.from_), self.to)
            self.current_value = value
            self.entry.delete(0, "end")
            self.entry.insert(0, self.current_value)
            self.command(self.current_value)
        else:
            self.entry.delete(0, 'end')
            self.entry.insert(0, self.current_value)
```
`validate_input()`でEntryWidgetの中身が数値であるかを確認し、数値であれば数値を上限と下限にクリップしてから`self.current_value`に数値を設定し指定されたコールバック関数を呼び出します。数値以外だった場合、コールバック関数を呼び出さず変更前の数値に戻します。
`validate_input()`については後で説明します。


## インクリメント/デクリメントボタンの作成
```python: SpinBox.py
        font = self.entry.cget("font")
        font_size = font[1] if type(font)==tuple else font.cget("size")
        self.up_button = ctk.CTkButton(self, text="▲", width=5, height=5, font=("Helvetica", int(font_size/2)), command=lambda: self.push_button(increment))
        self.down_button = ctk.CTkButton(self, text="▼", width=5, height=5, font=("Helvetica", int(font_size/2)), command=lambda: self.push_button(-increment))
```
インクリメント用とデクリメント用のボタンを作成しています。
フォントサイズを計算しているのは、EntryWidgetのフォントサイズに応じてボタンのフォントサイズも変更しデザインが崩れないようにするためです。
フォントの形式はTupleかCTkFontがあるため、型を確認してフォントサイズを取得する処理を変えています。

ボタン押下時に呼び出される関数は以下です。
```python: SpinBox.py
    def push_button(self, increment):
        current_value = float(self.entry.get())
        current_value += increment
        self.entry.delete(0, "end")
        self.entry.insert(0, current_value)

        self.update_value()
```
EntryWidgetの内容を取り出し、そこに`increment`を足し合わせてからEntryWidgetを書き直しています。その後、前述の`update_value()`を呼び出して値の確認とクリップ、コールバック関数の呼び出しをしています。

## EntryWidgetの内容のチェック
EntryWidgetは数値に限らず任意の文字列を入力出来てしまいます。今回は数値だけに限定したいので、入力内容が数値かどうかを判断する必要があります。
以下の`validate_input()`によって入力内容が数値であるかを判断しています。
```python: SpinBox.py
    def validate_input(self):
        value = self.entry.get()
        try:
            num = float(value)
            return True
        except ValueError:
            return False
```
始めにEntryWidgetから内容を取り出し、その内容をfloatに型変換します。この時正常に型変換が出来ればTrueを返します。正常に型変換が出来ずValueErrorの例外が発生したらFalseを返します。


# おわりに
本記事ではCustomTkinter版SpinBoxの実装とその解説をしました。

CustomTkinterはPythonでそれなりに見た目の良いGUIアプリがサクッと作れるので便利なのですが、Tkinterで出ていたことが出来なくなっているものもあるのでそこがちょっと残念ですね。
ただ、既存のWidgetを組み合わせて自分で作るのもそれはそれで楽しいなと感じています。

# 参考文献
https://customtkinter.tomschimansky.com/documentation/