作業環境
- Windows 10
- Visual Studio Code
- Python 3.11
概要
Labelを元に画像ボタンを作っていきます。
Labelを使う理由
Buttonのimage引数を指定することで簡単に画像をボタンを作ることができますが、以下の理由からLabelで作成しています。
- ttkbootstrapのButtonで画像を指定すると問答無用でパディングが確保される
- tkinterのButtonを使用すれば解決するが混合させたくなかった
- HoverやActiveなど状態ごとの画像指定が面倒だった
tkinter、ttkbootstrapで画像を扱う
tkinter、ttkbootstrapで画像を扱うには、Pillowやopencvで読み込んだ画像(Image、NDArray)を専用のImageクラス(以降「ImageTk」)に変換します。変換は関数ひとつで完了します。
# Pillowで画像読込 path = r"xxx\yyy\zzz.png" image = Image.open(path) # tkinter専用のImageクラスに変換 # PhotoImageの他にBitmapImageもある image_tk = ImageTk.PhotoImage(image)
image引数の罠
PythonさんとC++さん、お二方と面識のある方向けの説明になってしまいますが、Pythonさんは変数の寿命を参照カウンタで管理しています。そのため、C++さんほどGCを意識せずに変数を自由に使用できます。
このような背景もありクラスに指定した引数は、そのクラスも所有していることになり、参照カウンタが進むという設計がほとんどです。
然しながらTkinterのウィジェットクラス(ButtonやLabelの基底クラス)は、そういう設計ではないみたいです。
以下のように画像を指定するとGCでImageTkが消失し結果、画像が表示されない不具合が発生します。挙動的にはC++さんでいうところの生ポインタですかね。
class ImageButton(ttk.Label): def __init__( self, master:tk.Misc, ) -> None: path = r"xxx\yyy\zzz.png" image = Image.open(path) image_tk = ImageTk.PhotoImage(image) # image引数に指定された値は、おそらく生ポインタで扱われるため参照カウンタは進みません。 # その結果、コンストラクタ(__init__)を抜けるとカウンタがゼロになり、GCで無事(無事じゃない)消えます。 super().__init__(master, image=image_tk, borderwidth=0, padding=0)
初見でGCの知見がないと頭が???になる挙動な気がします。
そのため、ImageTkを扱う際には適当にselfで保持させてあげましょう。
class ImageButton(ttk.Label): def __init__( self, master:tk.Misc, ) -> None: path = r"xxx\yyy\zzz.png" image = Image.open(path) # self(ImageButton)がimage_tkを所持しているため、コンストラクタを抜けてもselfを破棄しない限り、参照カウンタは0にならない self.image_tk = ImageTk.PhotoImage(image) super().__init__(master, image=self.image_tk, borderwidth=0, padding=0)
便利なbind・callback(バインド・コールバック)
sequence | 呼び出しタイミング |
---|---|
<ButtonPress-1> | マウス左ボタンがクリックされた瞬間 |
<ButtonRelease-1> | マウス左ボタンのクリックが離された瞬間 |
<Enter> | ウィジェットにマウスカーソルが入った瞬間 |
<Leave> | ウィジェットからマウスカーソルが離れた瞬間 |
これらのbindに紐付けしてボタンの状態を決定しています。
状態管理にはビットフラグを使用していますが、ビットフラグがよく解らない・苦手な方はlist
に置き換えてin
, not in
で管理してもいいかもしれません。
理解できる範疇で実装するのが安全で楽しいですからね。
実装
import tkinter as tk import ttkbootstrap as ttk from ttkbootstrap.constants import * from enum import Enum from typing import Optional, Union, Callable from PIL import Image, ImageTk __all__ = [ "ImageButton", ] class ButtonState(Enum): """ボタンのステート """ NORMAL = 0x0 HOVER = 0x1 ACTIVE = 0x2 DISABLE = 0x4 class ImageButton(ttk.Label): """画像ボタン """ def __init__( self, master:tk.Misc, normal:Union[str, Image.Image], hover:Union[str, Image.Image], active:Union[str, Image.Image], disabled:Union[str, Image.Image], is_disabled:bool = False, command:Optional[Callable[[], None]] = None, ) -> None: """コンストラクタ Args: master (tk.Misc): master normal (Union[str, Image.Image]): 通常時の画像 hover (Union[str, Image.Image]): カーソルを合わせた時の画像 active (Union[str, Image.Image]): クリックした時の画像 disabled (Union[str, Image.Image]): 無効時の画像 is_disabled (bool, optional): ボタンの無効状態 (初期値). Defaults to False. command (Optional[Callable[[], None]], optional): カーソルを合わせた状態でクリックを離した時のコールバック. Defaults to None. """ self.normal_image = self.create_photo_image(normal) self.hover_image = self.create_photo_image(hover) self.active_image = self.create_photo_image(active) self.disabled_image = self.create_photo_image(disabled) super().__init__(master, image=self.disabled_image if is_disabled else self.normal_image, borderwidth=0, padding=0) self.bind("<ButtonPress-1>", self.on_left_button_press) self.bind("<ButtonRelease-1>", self.on_left_button_release) self.bind("<Enter>", self.on_enter) self.bind("<Leave>", self.on_leave) self.command = command # ボタンの状態 self.set_state(ButtonState.DISABLE if is_disabled else ButtonState.NORMAL) @property def is_hover(self) -> bool: """ボタンにマウスカーソルが置かれているか取得 Returns: bool: ボタンにマウスカーソルが置かれている場合はTrueを返します。 """ return self.is_set_state(ButtonState.HOVER) @property def is_active(self) -> bool: """ボタンがクリックされているか取得 ボタンにマウスカーソルが置かれているかは考慮しません。 Returns: bool: ボタンがクリックされている場合はTrueを返します。 """ return self.is_set_state(ButtonState.ACTIVE) @property def is_disable(self) -> bool: """ボタンが無効状態か取得 Returns: bool: 無効状態の場合はTrueを返します。 """ return self.is_set_state(ButtonState.DISABLE) @is_disable.setter def is_disable(self, value:bool) -> None: """ボタンの無効状態をセット Args: value (bool): 無効状態にする場合はTrueを指定します。 """ if value: # 無効状態は既存状態を全てクリアするため上書き self.set_state(ButtonState.DISABLE) self.configure(image=self.disabled_image) else: self.remove_state(ButtonState.DISABLE) self.configure(image=self.normal_image) def add_state(self, state:ButtonState) -> None: """ステートを追加 Args: state (ButtonState): ステート """ assert isinstance(state, ButtonState), "not support type." self.button_state |= state.value def set_state(self, state:ButtonState) -> None: """ステートをセット Args: state (ButtonState): ステート """ assert isinstance(state, ButtonState), "not support type." self.button_state = state.value def remove_state(self, state:ButtonState) -> None: """ステートを削除 Args: state (ButtonState): ステート """ assert isinstance(state, ButtonState), "not support type." self.button_state &= ~state.value def is_set_state(self, state:ButtonState) -> bool: """ステートがセットされているか Args: state (ButtonState): ステート Returns: bool: セットされている場合はTrueを返します。 """ assert isinstance(state, ButtonState), "not support type." return self.button_state & state.value def on_left_button_press(self, event:tk.Event) -> None: """左クリックが押された瞬間 Args: event (tk.Event): イベントプロパティ """ if self.is_disable: return self.add_state(ButtonState.ACTIVE) self.configure(image=self.active_image) def on_left_button_release(self, event:tk.Event) -> None: """左クリックが離された瞬間 Args: event (tk.Event): イベントプロパティ """ if self.is_disable: return is_call_command = self.is_active and self.is_hover self.remove_state(ButtonState.ACTIVE) self.configure(image=self.hover_image if self.is_hover else self.normal_image) if is_call_command: self.safe_command() def on_enter(self, event:tk.Event) -> None: """ボタンにマウスカーソルが置かれた、合わせた Args: event (tk.Event): イベントプロパティ """ if self.is_disable: return self.add_state(ButtonState.HOVER) self.configure(image=self.active_image if self.is_active else self.hover_image) def on_leave(self, event:tk.Event) -> None: """ボタンからマウスカーソルが離れた Args: event (tk.Event): イベントプロパティ """ if self.is_disable: return self.remove_state(ButtonState.HOVER) self.configure(image=self.hover_image if self.is_active else self.normal_image) def safe_command(self) -> None: """例外全無視コールバック実行 """ if self.command is None: return try: self.command() except Exception as e: pass @staticmethod def create_photo_image(path_or_data:Union[str, Image.Image]) -> ImageTk.PhotoImage: """PhotoImageの作成 画像読込や不正なImageの場合は、32x32ピクセルのパープルで塗りつぶした画像を作成します。 Args: path_or_data (Union[str, Image.Image]): 画像パスかImage Returns: ImageTk.PhotoImage: PhotoImage """ try: if isinstance(path_or_data, str): return ImageTk.PhotoImage(Image.open(path_or_data)) elif isinstance(path_or_data, Image.Image): return ImageTk.PhotoImage(path_or_data) except Exception as e: return ImageTk.PhotoImage(Image.new("RGB", (32, 32), (255, 0, 255)))
サンプル
リソースパス | 画像(右クリック > 名前を付けて画像を保存... でサンプルを再現可能です。) |
---|---|
resources\sample_normal.png | |
resources\sample_hover.png | |
resources\sample_active.png | |
resources\sample_disabled.png | |
resources\sample_sprite.png |
class SampleWindow(ttk.Window): def __init__(self) -> None: super().__init__() sample_paths = [ r"resources\sample_normal.png", r"resources\sample_hover.png", r"resources\sample_active.png", r"resources\sample_disabled.png", ] sample_sprite = cv2.imread(r"resources\sample_sprite.png") sample_sprite = cv2.cvtColor(sample_sprite, cv2.COLOR_BGR2RGB) sample_images = [ Image.fromarray(sample_sprite[ 0:20, 0:20], mode="RGB"), Image.fromarray(sample_sprite[ 0:20, 20:40], mode="RGB"), Image.fromarray(sample_sprite[ 0:20, 40:60], mode="RGB"), Image.fromarray(sample_sprite[ 0:20, 60:80], mode="RGB"), ] self.sample_image_button = ImageButton(self, *sample_paths) self.sample_image_button.grid(column=0, row=0, sticky=W) self.sample2_image_button = ImageButton(self, *sample_images) self.sample2_image_button.grid(column=1, row=0, sticky=W) self.sample3_image_button = ImageButton(self, *sample_paths, is_disabled=True) self.sample3_image_button.grid(column=2, row=0, sticky=W) self.sample4_image_button = ImageButton(self, *sample_images, is_disabled=True) self.sample4_image_button.grid(column=3, row=0, sticky=W) if __name__ == "__main__": app = SampleWindow() app.mainloop()