Tkinterで、image付きListbox的なモノを実装してみた( Tkinter List with image and text

f:id:lynmock:20220214095512p:plain
初めてのPythonソフトとして、こんなのを作った、という話です

先日、Raspberry Pi4にSSDを奢ってみたので、せっかくだからPythonを使えるようになって積極的にオモチャにしてやろうと勉強することにしました。

f:id:lynmock:20220214140418j:plain

まずは軽くTwitterクライアント*1を作ってみようと考えたものの、Pythonの主流GUIライブラリであるTkinterには、画像の使えるListboxがない様子。
Stack Overflowを見ても「Listboxで画像を使う方法を教えて」「そんな機能はない」などというやり取りが見受けられ、軽く検索してみても、それを実現するコードは見つかりませんでした。なんか大昔にTwitterクライアントを作ろうとAWTの勉強をしてみたものの同様のことで躓き、Swingに転がったのを思い出しますね。
問題は今回はライセンスとか考えると、そういう都合のいい別のライブラリがないところですが。

てなわけで、先の3連休の最終日を潰して画像の使えるListbox的なモノを丁稚UPしてみました。まあ多分本格的にやってるヒト達はこんなのホイホイと作ってるのでしょうが、検索した時に何も出てこないのは初心者には辛かろう、という意味での公開です…。

import tkinter as tk
import tkinter.ttk as ttk
from PIL import Image, ImageTk

class ScrollableFrame(ttk.Frame):
    # based https://daeudaeu.com/scrollbar/
    def __init__(self, container):
        super().__init__(container)
        self.canvas = tk.Canvas(self)
        self.scrollable_frame = ttk.Frame(self.canvas)
        self.scrollable_frame.bind(
            "<Configure>",
            lambda e: self.canvas.configure(
                scrollregion=self.canvas.bbox("all")
            )
        )
        self.canvas.bind("<MouseWheel>", self.mouse_y_scroll)
        self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
        self.scrollbar_y = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
        self.scrollbar_y.pack(side=tk.RIGHT, fill="y")
        self.canvas.configure(yscrollcommand=self.scrollbar_y.set)
        self.canvas.pack(side=tk.LEFT, fill="both", expand=True)
        self.canvas.focus_set()
    def mouse_y_scroll(self, event):
        unitCount = 0
        if event.delta > 0:
            unitCount = -1
        elif event.delta < 0:
            unitCount = 1
        self.canvas.yview_scroll(unitCount, 'units')

class WrappingLabel(ttk.Label):
    def __init__(self, master=None, **kwargs):
        ttk.Label.__init__(self, master, **kwargs)
        # -90 is a correction value to avoid overlapping Label and scrollbar.
        self.bind('<Configure>', lambda e: self.config(wraplength=frame.winfo_width() - 90))
        self.bind("<MouseWheel>", frame.mouse_y_scroll)
        self.bind("<Button-1>", self.click)
    def setIndex(self, i):
        self.rowIndex = i
    def click(self, event):
        global selectedIndex,label_list
        if selectedIndex == self.rowIndex:
            return
        set_list_background(0, self.rowIndex)            


def press_down_key(self):
    global selectedIndex,label_list
    if (selectedIndex < 0 or selectedIndex == len(label_list) -1):
        return
    set_list_background(1)
   
def press_up_key(self):
    global selectedIndex,label_list
    if (selectedIndex <= 0):
        return
    set_list_background(-1)

def set_list_background(indexCorrect, newIndex = -1):
    global selectedIndex, label_list
     # Return the selected label to the original background color
    selectedLabel = label_list[selectedIndex]
    selectedLabel.config(foreground = 'black', background = get_list_background(selectedIndex)) 
    # Change the background color of the newly selected label to blue
    if newIndex >= 0:
        selectedIndex = newIndex #click
    else:
        selectedIndex = selectedIndex + indexCorrect
    selectedLabel = label_list[selectedIndex]
    selectedLabel.config(foreground = 'white', background = '#2080ff')

def get_list_background(index):
    if  index%2 == 0:
        return "#FFFFFF"
    else:
        return "#DDDDDD"


root = tk.Tk()
root.geometry("500x700+0+0")
root.bind("<Down>", press_down_key)
root.bind("<Up>", press_up_key)

style = ttk.Style()
style.theme_use("classic")

frame = ScrollableFrame(root)
frame.pack(fill="both", expand=True)

image_list = []
label_list = []
selectedIndex = -1

for i in range(100):

    img = Image.open('icon.png')
    img = img.resize((48, 48))
    img = ImageTk.PhotoImage(img)
    image_list.append(img)

    label = WrappingLabel(
        frame.scrollable_frame, 
        text = str (i) +":Good morning. In less than an hour, aircraft from here will join others from around the world. And you will be launching the largest aerial battle in the history of mankind. Mankind. That word should have new meaning for all of us today. ", 
        image = image_list[i], 
        foreground = "black",
        background = get_list_background(i),
        compound = "left",
        padding = [10],
        wraplength = 100)
    label.pack(expand=True, fill=tk.X) 
    label.setIndex(i)
    label.bind("<MouseWheel>", frame.mouse_y_scroll)

    label_list.append(label)
    
root.mainloop()

Labelデザインやデータの構造の部分は都合のいいように変更して使ってください。挙動はこちらに動画を上げてみましたのでご確認ください。

www.youtube.com

開発&動作確認はMac OS Monterey、Python 3.10.2(2.7.18かも)で行いました(RaspberryPiじゃないのかよ)
何しろPythonについては素人なので、修正すべきところがあったら教えてくれると嬉しいです。

あ、あと、気に入ったヒトは、お財布が温かい時に気が向いたら、こちらの欲しい物リストから何かくれると私が超喜びますw

ほんじゃね。

※ScrollableFrameの部分は https://daeudaeu.com/scrollbar/ に掲載されたコードを元にしています。


追伸
本当はカーソルの位置に追従してスクロールするのを実装していたのですが、スクロールバーをクリックした時にどうしたらいいのかわからんので諦めました

www.youtube.com
下に行く時だけできた…まあ気が向いたら完成させます

考えてみたらコイツらを使えばスマートにできたんだ…

*1:Twitterクライアントって色々な機能が要求されるので、一つの言語やフレームワークを覚えるのに都合がいいのです