ttkbootstrap 画像ボタンを作ってみた

作業環境

概要

Labelを元に画像ボタンを作っていきます。


Labelを使う理由

Buttonのimage引数を指定することで簡単に画像をボタンを作ることができますが、以下の理由からLabelで作成しています。

  • ttkbootstrapのButtonで画像を指定すると問答無用でパディングが確保される
    • tkinterのButtonを使用すれば解決するが混合させたくなかった
  • HoverやActiveなど状態ごとの画像指定が面倒だった
左4つがLabelに画像指定、右1つがButtonに画像指定

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()