Python tkinter GUIプログラミング ツールチップ

 今日も見に来てくださって、ありがとうございます。石川さんです。

 今回は、tkinterでツールチップを出力する方法を調べてみました。いい感じにできたのでまとめてみます。もともと、ホバーイベントって、どうやるのかなぁ、というのが発端でした。ホバーとは、マウスを同じところでじっとしていると発生するイベントです。tkinterのイベントの中にホバーは見当たらなかったので、たぶん、そんなイベントは存在しないのだと思います。

出来上がりイメージ

ソースコード

import tkinter as tk

class ToolTip():
    def __init__(self, widget, text="default tooltip"):
        self.widget = widget
        self.text = text
        self.widget.bind("<Enter>", self.enter)
        self.widget.bind("<Motion>", self.motion)
        self.widget.bind("<Leave>", self.leave)
        self.id = None
        self.tw = None

    def enter(self, event):
        self.schedule()
    
    def motion(self, event):
        self.unschedule()
        self.schedule()
    
    def leave(self, event):
        self.unschedule()
        self.id = self.widget.after(500, self.hideTooltip)
    
    def schedule(self):
        if self.tw:
            return
        self.unschedule()
        self.id = self.widget.after(500, self.showTooltip)
    
    def unschedule(self):
        id = self.id
        self.id = None
        if id:
            self.widget.after_cancel(id)
    
    def showTooltip(self):
        id = self.id
        self.id = None
        if id:
            self.widget.after_cancel(id)
        x, y = self.widget.winfo_pointerxy()
        self.tw = tk.Toplevel(self.widget)
        self.tw.wm_overrideredirect(True)
        self.tw.geometry(f"+{x+10}+{y+10}")
        label = tk.Label(self.tw, text=self.text, background="lightyellow",
                         relief="solid", borderwidth=1, justify="left")
        label.pack(ipadx=10)

    def hideTooltip(self):
        tw = self.tw
        self.tw = None
        if tw:
            tw.destroy()

if __name__ == "__main__":
    root = tk.Tk()
    root.title("Tooltip test")
    root.geometry("400x100")
    button = tk.Button(root,text="test button")
    button.pack()
    tooltip = ToolTip(button)
    button2 = tk.Button(root, text="next button")
    button2.pack()
    tooltip_text = "ツールチップをセットすることができます。\n改行コードを入れることで複数行になります。"
    tooltip2 = ToolTip(button2, tooltip_text)
    root.mainloop()

詳細説明

 今回は、汎用的に利用できる、ウィジェットではないToolTipクラスを作成してみました。利用方法は、61行目と65行目の通りです。それぞれ、ボタンのツールチップを設定しています。インスタンス作成時にウィジェットとツールチップに表示する文字列を渡します。

 ポイントは、ウィジェットにマウスポインタが入った時の<Enter>イベントと出て行った時の<Leave>イベント、それとマウスポインタが入っている間に発生する<Motion>イベントでそれぞれ、ツールチップの表示をスケジュールする、ツールチップの消去をスケジュールする、動いている間はスケジュールをやり直す、というところでしょうか。

 <Enter>イベントが発生したときにスケジュールするのに、28行目の「self.id = self.widget.after(500, self.showTooltip)」のafter()メソッドを使用しています。これで500ミリ秒後に、self.showTooltip()を実行してね、とスケジュールしています。これにより、イベント発生後に何もしなければ、ツールチップが表示されるようになります。

 <Leave>イベントが発生したときは、既に表示されているはずのツールチップを消去する必要がありますので、ここでも500ミリ秒後に消去するメソッドを呼び出すようスケジュールしています。これが28行目の「self.id = self.widget.after(500, self.hideTooltip)」になります。

 ツールチップ自体は、Toplevelを使って表示しています。43行目の「self.tw.wm_overrideredirect(True)」を呼び出すことによって、ウィンドウマネージャがこのウィジェットを無視するようにします。要するに、ウィンドウタイトルや最小化、最大化、ウィンドウを閉じるボタンなどをセットしないようになります。

 表示位置を決めるために、41行目の「self.widget.winfo_pointerxy()」を利用しています。ポインタの位置にウィンドウを表示すると、表示したウィンドウにポイントすることになるので<Leave>イベントが発生し、これによりウィンドウが消去される、という循環が発生してしまうので、位置を10ピクセルずつ少しずらして表示するよう工夫しました。

まとめ

 ホバーというイベントはなくても、ツールチップを出すことができました。これでマウスがじっとしているときに何かするプログラムが作れるようになりましたね。

Python GUIプログラミング Canvas Text入力

 今日も見に来てくださって、ありがとうございます。石川さんです。

 TkinterCanvasでテキストを入力することができるかどうか、ということで調べてみました。先日、Canvasのメソッドのcreate_text()でテキストを描画することはできたのですが、いろいろと調査した結果、このテキストを編集することができる、ということがわかりましたので、まとめます。

実行イメージ

キャンバス上のテキストを編集可能にしました

 マウスクリックでテキストを選択、矢印キーでカーソル位置を移動、HomeキーやEndキーが使えます。Enterキーで確定、Escキーで取り消し、タブキーで次の項目へ移動、シフトキーと移動キーで部分選択が可能です。

ソースコード

import tkinter as tk

class Application(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Canvas text edit")
        self.geometry("400x280")
        self.canvas = tk.Canvas(self, background="white")
        self.canvas.pack(fill=tk.BOTH, expand=True)
        self.canvas.tag_bind("editable","<Button-1>", self.clicked)
        self.canvas.tag_bind("editable","<Key>", self.do_key)
        
        self.focused_item = None
        self.focused_item_text = None
        
        self.canvas.create_text(20,80, anchor=tk.NW, text="このテキストは修正可能です。", tags=("editable",))
        self.canvas.create_text(20, 120, anchor=tk.NW, text="このテキストも修正可能です。", tags=("editable",))
        self.canvas.create_text(20, 160, anchor=tk.NW, text="このテキストも修正可能です。", tags=("editable",))

    def clicked(self,event):
        if self.canvas.type(tk.CURRENT) == "text":
            prev_item = self.focused_item
            self.canvas.focus_set() 
            self.canvas.focus(tk.CURRENT)
            x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
            self.focused_item = self.canvas.find_overlapping(x, y, x, y)
            if prev_item == self.focused_item:
                x = self.canvas.canvasx(event.x)
                y = self.canvas.canvasy(event.y)
                if event.state&1:
                    index_current = self.canvas.index(tk.CURRENT, tk.INSERT)
                    index_selected = self.canvas.index(tk.CURRENT, "@%d,%d" % (x, y))
                    if index_current <= index_selected:
                        self.canvas.select_from(tk.CURRENT, index_current)
                        self.canvas.select_to(tk.CURRENT, index_selected)
                    else:
                        self.canvas.select_from(tk.CURRENT, index_selected)
                        self.canvas.select_to(tk.CURRENT, index_current - 1)
                else:
                    self.canvas.icursor(self.focused_item, "@%d,%d" % (x, y))
                    self.canvas.select_clear()
            else:
                self.canvas.select_from(tk.CURRENT, 0)
                self.canvas.select_to(tk.CURRENT, tk.END)
            if self.focused_item:
                self.focused_item_text = self.canvas.itemcget(self.focused_item,"text")
            else:
                self.focused_item_text = None

    def do_key(self, event):
        if event.keycode == 229: # IME入力中
            return
        item = self.canvas.focus()
        if item:
            current_index = self.canvas.index(item,tk.INSERT)
            if event.keysym == 'Right':
                new_index = current_index + 1
            elif event.keysym == 'Left':
                new_index = current_index - 1
            elif event.keysym == 'End':
                new_index = self.canvas.index(item,tk.END)
            elif event.keysym == 'Home':
                new_index = self.canvas.index(item,0)
            elif event.keysym == 'BackSpace':
                selection = self.canvas.select_item()
                if selection:
                    self.canvas.dchars(item, tk.SEL_FIRST, tk.SEL_LAST)
                else:
                    if current_index > 0:
                        self.canvas.dchars(item, current_index - 1)
                return
            elif event.keysym == 'Delete':
                selection = self.canvas.select_item()
                if selection:
                    self.canvas.dchars(item, tk.SEL_FIRST, tk.SEL_LAST)
                else:
                    self.canvas.dchars(item, current_index)
                return
            elif event.keysym == 'Tab':
                items = self.canvas.find_withtag("editable")
                if items:
                    index = items.index(item)
                    if index + 1 == len(items):
                        next_item = items[0]
                    else:
                        next_item = items[index + 1]
                    self.canvas.focus(next_item)
                    self.canvas.select_from(next_item,0)
                    self.canvas.select_to(next_item,tk.END)
                return
            elif event.keysym == 'Return':
                self.canvas.select_clear()
                self.canvas.focus("")
                self.focused_item = None
                self.focused_item_text = None
                return
            elif event.keysym == 'Escape':
                self.canvas.itemconfig(item, text=self.focused_item_text)
                self.canvas.select_clear()
                self.canvas.focus("")
                self.focused_item = None
                self.focused_item_text = None
                return
            elif event.keycode in (16, 17): # Shift, Ctrl
                return

            if event.char >= ' ':
                selection = self.canvas.select_item()
                if selection:
                    new_index = self.canvas.index(item, tk.SEL_FIRST) + len(event.char)
                    self.canvas.dchars(item, tk.SEL_FIRST, tk.SEL_LAST)
                    self.canvas.select_clear()
                else:
                    new_index = current_index + len(event.char)
                self.canvas.insert(item, tk.INSERT, event.char)

            if event.state&1: # Shift Key
                selection = self.canvas.select_item()
                if selection:
                    if self.canvas.index(item,tk.SEL_LAST) >= new_index:
                        self.canvas.select_from(item, new_index)
                        self.canvas.select_to(item, tk.SEL_LAST)
                    else:
                        self.canvas.select_from(item, tk.SEL_FIRST)
                        self.canvas.select_to(item, new_index - 1)
                    self.canvas.icursor(item, new_index)
                else:
                    self.canvas.select_from(item, current_index)
                    if current_index < new_index:
                        self.canvas.select_to(item, new_index - 1)
                    else:
                        self.canvas.select_to(item, new_index)
                    self.canvas.icursor(item, new_index)
            else:
                self.canvas.icursor(item, new_index)
                self.canvas.select_clear()
                

if __name__ == "__main__":
    application = Application()
    application.mainloop()

 ソースコード、めっちゃ長くなってしまいました。もっと短くしたいですよねぇ。

解説

 いやー、今回のやりたいこと、割と基本的なことばかりなので、もっと簡単に実装できるのかと思っていました。それが、意外や意外、時間もかかったし、ソースコードも長くなって、ブログで紹介するのを躊躇するほどのボリュームになってしまいました。(あと、実は、バグも残ってるんですよ。ここだけの話。全部さっき気づきましたが、シフトキー押しながら大文字とか記号を入力すると、なぜか選択されちゃったり、他にもCtrl+Vで張り付けられなかったり、ファンクションキーを押すとUnboundLocalErrorがraiseしたりとか、入力をキャンセルしようとして選択されていないキャンバス上のどこかをクリックしても反応しなかったりと、いろいろ足りないことがありましたので、ブログの更新を優先することにして、そのあたりの実装は、今回はあきらめました。ちょっと整理が必要ですね。)

 今回の一つ目のポイントは11、12行目のtag_bind()です。Canvas上の要素は、連番で識別される、idか、ユーザーが定義したタグという概念で管理することができるようです。このタグにイベントをバインドする(割り当てる)ことができるのがこのtag_bind()です。idだと一つの要素にのみ影響することができますが、タグを使うことで複数の要素に対して同時に影響を及ぼすことが可能になります。今回はここで、editable(編集可能)というタグを定義しました。

 16~18行目では、create_text()実行するときにこのタグをセットしています。これでクリックされたときのイベント<Button-1>と、キー入力されたときのイベント<Key>に対する処理が割り当てられます。

 20行目からは、クリックされたときのイベントの処理になります。ソースコード上でうまく表現できていないのですけど、最初のテキストを選択したとき、と、選択されたテキストのカーソル位置を変更するとき、の、二種類の処理が混ざっています。いろいろ編集された後にEscapeキーを押されて元に戻すためのデータもこのタイミングで保持するようにしています。クリックしたときと前にクリックしたときの項目が同じならカーソル位置の変更、そうでないなら最初のテキストの選択、という風に判断しています。

 50行目から、キー入力されたときのイベントの処理です。このメソッド、長すぎますね!何とかせんかーい、と、言いたくなります。自分にですけど。いろいろと継ぎ足し継ぎ足しして作っていったのが、よくわかります。51行目の「if event.keycode == 229:」ですが、なんと、IMEで日本語入力中、つまり返還前のキー入力は、このkeycodeが229としてイベントが発生してきました。IMEの入力に対する処理は不要なので、無視するように変更しました。ドキュメントのどこかに書いてあればよいのですけど、見つけられませんでした。でも、これでよいですよね?どなたか詳しい方、教えてください♪

 それ以降は、おおよそ何をやっているかというと、カーソルの移動に対応しております。キー入力で右に行ったり左に行ったりするのも、自動では対応されていないので、自分でコーディングする必要があるのですね。もちろん、HomeキーやEndキー、Backspaceキー、Deleteキーの動きも、自分でコーディングする必要がありました。それぞれの移動時にShiftキーが押されていた時は選択したいのですけど、それも当然自分でコーディングする必要がありました。Entryウィジェットなどは、ちゃんと処理されているのですが、Canvas上の項目については定義されていません。これは、面倒ですが、、、動作を自由にカスタマイズができる、ということですね!

 91行目、Returnキーを入力したときに、確定、97行目、Escapeキーを入力したときに入力をキャンセルするように実装してみました。そして、104行目は、Shiftキー、Ctrlキーが単独で押されたときのイベントですね。後続の処理をしないようにしました。

 ちなみに、シフトキーが押された状態かどうか、というのは、117行目のif event.state&1:で判定しています。ステータスの1ビット目がセットされてくるようです。これもドキュメントに書かれているところが見つけられなかったのですよねぇ。

まとめ

 Canvascreate_text()メソッドで、テキスト入力項目を作る、というのはちょっとハードルが高い、ということがわかりました。もうちょっと整理して、共通部品の作り方を検討した方がよさそうです。

Python tkinter GUIプログラミング アウトライン2

 今日も見に来てくださって、ありがとうございます。石川さんです。

 前回、アウトラインの案として、キャンバスの中に、create_window()でキャンバスを追加する、というやり方で実装してみましたが、Toplevel()で別ウィンドウとして実装した方がよいかも、ということで、前回のソースコードに追記してみました。

出来上がりイメージ

Outlineサンプル

 Toplevel()を継承したOutlineウィンドウを追加しました。

ソースコード

 ソースコードは以下の通りです。

import tkinter as tk

class Outline(tk.Toplevel):
    def __init__(self, master=None, cnf={}, **kw):
        super().__init__(master, cnf, **kw)
        self.title("Outline")
        self.canvas = tk.Canvas(self, background="lightblue")
        self.canvas.pack(fill=tk.BOTH,expand=True)
        master.update()
        print(master.winfo_width(),master.winfo_height())
        w, h = master.winfo_width()//5, master.winfo_height()//5
        self.geometry(str(w)+"x"+str(h))
        self.resizable(0,0)
        self.attributes("-toolwindow",1)
        self.attributes("-topmost",1)

    def get_canvas(self):
        return self.canvas

class Application(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Canvas outline sample")
        self.outline = None
        self.outline_item = None
        
        self.canvas = tk.Canvas(self, background="white")
        self.canvas.pack(fill=tk.BOTH,expand=True)
        self.canvas.bind("<1>",self.update_outline)
        self.canvas.bind("<Configure>",self.update_outline)
        
        self.focus_force()
        
        self.outline_window = Outline(master=self)

    def update_outline(self, event):
        if self.outline == None or self.outline_item == None:
            self.outline = o = tk.Canvas(self, background="lightblue")
            c = self.canvas
            width, height = c.winfo_width(), c.winfo_height()
            w, h = width//5, height//5
            x, y = width - w//2, height - h//2
            self.outline_item = c.create_window(x, y, width=w, height=h, window=o)
            return

        if event.type == tk.EventType.Configure:
            c = self.canvas
            width, height = c.winfo_width(), c.winfo_height()
            w, h = width//5, height//5
            x, y = width - w//2, height - h//2
            oi = self.outline_item
            x0, y0 = c.coords(oi)
            dx = x - x0
            dy = y - y0
            c.move(oi,dx,dy)
            c.itemconfigure(oi,width=w,height=h)
            self.outline_window.geometry(str(w)+"x"+str(h))

        elif event.type == tk.EventType.Button:
            c = self.canvas
            x, y = event.x, event.y
            c.create_rectangle(x, y, x+100, y+100)
            o = self.outline
            o.create_rectangle(x//5,y//5,(x+100)//5,(y+100)//5)
            c = self.outline_window.get_canvas()
            c.create_rectangle(x//5,y//5,(x+100)//5,(y+100)//5)
    
if __name__ == "__main__":
    application = Application()
    application.mainloop()

詳細

 3~18行目まででOutlineウィンドウを追加しました。ポイントは9行目のmaster.update()でしょうか。これを実行することで待機中のイベントが実行されて、ウィンドウのサイズが計算されます。もし実行しない場合は、master.winfo_width()master.winfo_height()が計算されていない状態、1と1になって、とっても小さいウィンドウが表示されます。ほとんど何も見えません。

 13、14行目は、それぞれ、self.resizable(0,0)でサイズの変更をできないようにして、self.attributes("-toolwindow",1)で最小化と最大化のボタンを非表示にしています。15行目のself.attributes("-topmost",1)では、ずっと画面の一番上に表示されるように設定しています。常に前面で表示されるようになります。

 57行目で、メインウィンドウの大きさが変わった時に同じようにアウトラインも大きさが変わるように動作させています。

まとめ

 浮かんだウィンドウの方が、しっくりきますね。キャンバス上の実装はそのままでは移動できませんので、移動できるという点でも優れていると思います。本体部分がちゃんと作れれば、アウトライン部分も割と簡単に実装できそうですね。

Python tkinter GUIプログラミング アウトライン

 今日も見に来てくださって、ありがとうございます。石川さんです。

 GUIのエディターを作ったときに、アウトラインを表示する方法をちょっと検討してみました。キャンバスの中にcreate_window()でキャンバスを作成して、同じ内容を描画する、というのでどうかなぁ、ということで、ちょっとお試ししてみました。

できあがりイメージ

右下にアウトライン(鳥瞰図)表示

 クリックした個所に四角形を描画します。描画と同時に右下のアウトラインにも描画します。

ソースコード

 ソースコードは以下の通りです。

import tkinter as tk

class Application(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Canvas outline sample")
        self.outline = None
        self.outline_item = None
        
        self.canvas = tk.Canvas(self, background="white")
        self.canvas.pack(fill=tk.BOTH,expand=True)
        self.canvas.bind("<1>",self.update_outline)
        self.canvas.bind("<Configure>",self.update_outline)

    def update_outline(self, event):
        if self.outline == None or self.outline_item == None:
            self.outline = o = tk.Canvas(self, background="lightblue")
            c = self.canvas
            width, height = c.winfo_width(), c.winfo_height()
            w, h = width//5, height//5
            x, y = width - w//2, height - h//2
            self.outline_item = c.create_window(x, y, width=w, height=h, window=o)
            return

        if event.type == tk.EventType.Configure:
            c = self.canvas
            width, height = c.winfo_width(), c.winfo_height()
            w, h = width//5, height//5
            x, y = width - w//2, height - h//2
            oi = self.outline_item
            x0, y0 = c.coords(oi)
            dx = x - x0
            dy = y - y0
            c.move(oi,dx,dy)
            c.itemconfigure(oi,width=w,height=h)

        elif event.type == tk.EventType.Button:
            c = self.canvas
            x, y = event.x, event.y
            c.create_rectangle(x, y, x+100, y+100)
            o = self.outline
            o.create_rectangle(x//5,y//5,(x+100)//5,(y+100)//5)
    
if __name__ == "__main__":
    application = Application()
    application.mainloop()

 ウィンドウのサイズを変更したら、アウトラインのサイズも変更されます。

まとめ

 今回は、クリックしたときに同時に小さいサイズの描画をしました。実際のGUIエディターでは、アウトラインの表示、非表示の切り替えができるようにする必要がありそうですね。スクロールとか、拡大縮小も対応しなければいけないでしょうね。

Python GUIプログラミング Rounded Rectangle

 今日も見に来てくださって、ありがとうございます。石川さんです。

 Tkinterでいろいろとできるようになってきたのですけど、ふと、角が丸くなった四角形を描くのはどうやったらいいのでしょうか、と、気になったので調べてみました。最終的にうまくいったのでまとめておきます。

できあがりイメージ

角が丸い矩形

ソースコード

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Rounded rectangle")
        self.geometry("430x220")
        
        self.canvas = c = tk.Canvas(self)
        c.pack(fill=tk.BOTH,expand=True)
        self.rounded_rectangle(90,50,340,150,25,fill="lightblue",outline="black")
        self.canvas.bind("<ButtonPress>",self.click)
        self.canvas.bind("<ButtonRelease>",self.release)
        self.canvas.bind("<Motion>",self.move)
        self.start = None
        
    def rounded_rectangle(self, x1, y1, x2, y2, r=25, **kwargs):
        points = [
                  x1, y1,
                  x1+r, y1,
                  x2-r, y1,
                  x2, y1,
                  x2, y1+r,
                  x2, y2-r,
                  x2, y2,
                  x2-r, y2,
                  x1+r, y2,
                  x1, y2,
                  x1, y2-r,
                  x1, y1+r,
                  ]
        self.canvas.create_polygon(points,**kwargs,smooth=True,tags="r1")
    
    def click(self,event):
        self.start = (self.canvas.canvasx(event.x), self.canvas.canvasy(event.y))
            
    def release(self, event):
        self.start = None

    def move(self,event):
        if not self.start:
            return
        original_x, original_y = self.start
        x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
        self.canvas.move("r1",x - original_x, y - original_y)
        self.start = (x, y)

if __name__ == '__main__':
    app = App()
    app.mainloop()

説明

 ここでは、rounded_rectangle()というメソッドを作成して描画するようにしました。何のことはない、ポイントはキャンバスのcreate_polygon()で描画するところなのですが、特にsmooth=Trueを指定しているところでしょう。ちょっと変わったところに点を打っているのですけど、smooth=Trueを指定するだけで、このような描画が可能になりました。

 その他、click()、release()、move()などを作りましたが、どうやって矩形を動かせばいいのかと、ちょっと復習してみました。描いた矩形はマウスで移動できます。

まとめ

 角が丸い矩形はCanvasで実装しておいてほしかったよねぇ。。。(笑)

Python Tkinter GUIプログラミング 数字合わせ

 今日も見に来てくださって、ありがとうございます。石川さんです。

 今回紹介する数字合わせは、先日読んだ書籍に、作業記憶と注意力を鍛え、認知プロセスをスピードアップする練習として紹介されていました。練習を積むと、直観が信じられるようになるとか。作業記憶が鍛えられると一度見たものをもう一度確認しなくても済むようになるそうです。作業記憶と、注意力、鍛えたいですねぇ。

 実際は、トランプをつかってやるのですけど、せっかくtkinterでプログラムが作れるので、ちょっと作ってみることにしました。やり方は、神経衰弱と同じです。実行するとカードが4×3の12枚配られた状態になります。クリックするとマークが表示されます。2枚ずつクリックして、数字を一致させます。

できあがりイメージ

数字合わせ 4×3

ソースコード

from tkinter import Canvas, Tk, BOTH, ALL
from tkinter import messagebox
from math import sqrt, ceil, floor
from random import sample
from collections import namedtuple

Suit = namedtuple('Suit',['figure','color','text'])
Club = Suit("\u2663","#000000","Club")
Heart = Suit("\u2665","#FF0000","Heart")
Spade = Suit("\u2660","#000000","Spade")
Diamond = Suit("\u2666","#FF0000","Diamond")
Suits = [Club,Heart,Spade,Diamond]
Rank = ["A","2","3","4","5","6","7","8","9","10","J","Q","K"]
Card = namedtuple('Card',['suit','rank'])
N = 6

class NumberMaching(Tk):
    def __init__(self):
        super().__init__()
        self.title("Number Maching")
        self.state("zoomed")
        self.cards = [Card(suit, r) for r in Rank[:N] for suit in Suits[:2]]

        self.canvas = Canvas(self)
        self.canvas.pack(expand=True,fill=BOTH)
        self.refresh_cards()

    def refresh_cards(self):
        self.canvas.delete(ALL)
        w, h = self.winfo_screenwidth(), self.winfo_screenheight()
        width, height = ceil(sqrt(N*2)), floor(sqrt(N*2))
        if N*2 > width * height:
            height += 1
        wm, hm = w // (width*2), h // (height*2) # width margin, height margin
        self.q = sample(self.cards,len(self.cards))
        self.items = [None] * len(self.q)
        self.answers = []
        self.closing = False
        self.tapped = 0

        for i in range(height):
            for j in range(width):
                n = i*width + j
                if n >= N*2:
                    break
                figure = self.q[n].suit.figure + self.q[n].rank
                color = self.q[n].suit.color
                x = j*((w-wm)//width) + wm
                y = i*((h-wm)//height) + hm
                item = self.canvas.create_text(x,y,text=figure,fill=color,font=("",60),tags="card")
                rect = self.get_rectangle(x,y,(w-wm)//width*.9,(h-wm)//height*.9)
                carditem = self.canvas.create_rectangle(rect,fill="white",tags="card")
                self.items[n] = [item, carditem, self.q[n].rank] # text, rectangle, rank
        self.canvas.tag_bind("card","<Button-1>",self.card_tapped)

    def get_rectangle(self,center_x,center_y,width,height):
        leftx = center_x - width // 2
        topy = center_y - height // 2
        rightx = center_x + width // 2
        bottomy = center_y + height //2
        return (leftx,topy,rightx,bottomy)

    def card_tapped(self,event):
        if self.closing:
            return
        self.tapped += 1
        item = self.canvas.find_closest(event.x,event.y)
        for n, a in enumerate(self.items):
            if a[0] == item[0] or a[1] == item[0]:
                break
        if n > N * 2:
            return
        isopen = False
        for i in self.answers:
            if i == n:
                isopen = True
        if isopen:
            if self.answers[-1] == n:
                if self.items[self.answers[-1]][2] == self.items[answers[-2]][2]:
                    pass #すでに正解しているときは、閉じない
                else:
                    self.canvas.tag_raise(a[1])
                    self.answers.pop()
        else:
            self.canvas.tag_lower(a[1])
            self.answers.append(n)
            if len(self.answers) % 2 == 0:
                if self.items[self.answers[-1]][2] == self.items[self.answers[-2]][2]:
                    # 同じ数字が選択されました。
                    if len(self.answers) == len(self.items):
                        message_string = ("トレーニング終了です。\n" 
                                          "問題数:" + str(N) + "\n"
                                          "タップ数:" + str(self.tapped) + "\n\n"
                                          "もう一度、やりますか?")
                        if messagebox.askyesno("Congraturation!", message_string):
                            self.refresh_cards()
                        else:
                            self.destroy()
                else:
                    self.after(500, self.close_card) # 違う数字が選択されました。
                    self.closing = True

    def close_card(self):
        if len(self.answers) < 2:
            return
        self.canvas.tag_raise(self.items[self.answers[-1]][1])
        self.answers.pop()
        self.canvas.tag_raise(self.items[self.answers[-1]][1])
        self.answers.pop()
        self.closing = False
        
if __name__ == '__main__':
    numberMaching = NumberMaching()
    numberMaching.mainloop()

説明

 もっと簡単にできると思っていましたが、意外と作るのに時間がかかってしまいました。まず、カードのマークをどうしようかなぁ、ということで、グーグル先生に相談してみました。Unicodeにトランプのマークがあったので、それを利用することにしました。初期データは、namedtupleをつかって作ることにしました。7~14行目と22行目です。あと、カードをシャッフルするのは、randomモジュールからsampleをつかっています。35行目です。順番を入れ替えるだけならshuffleでも同じように動きます。sampleshuffleの違いは、sampleは新しいリストをつくるのに対して、shuffleは指定されたリストの順序を入れ替える、という点です。

 画面のカードを描画する部分は再実行したいときに、呼び出せるようにrefresh_cards()メソッドにまとめました。28~54行目です。キャンバス上にcreate_text()でカードのマークを描画して、その上からcreate_rectangle()で白い長方形を描画してカードを表現しました。これらの項目を作成するときに、"card"タグをセットして、クリックしたときに何らかの処理ができるよう、card_tapped()メソッドをバインドしました。

 めくったカードの正解、不正解については、めくられたカードを保存するようにリストを使うことにしました。37行目のself.answers = []がその宣言部分です。カードがめくられるたびに、このanswersappend()で追加していきますが、追加したあとの判定で、めくった枚数が奇数のときはなにもしない、偶数のときは二枚目がめくられたということで、チェックするようにしました。

 チェックは、rankが同じなら数値が一致したということで何もしないのですが、不一致の場合は、カードを元通りにもどす、ということをやるようにしました。すべてめくられたら、終了メッセージを出力しています。

まとめ

 もっと簡単にできるのかと思っていましたが、意外と時間がかかってしまった原因は、カードの位置決め部分に汎用性を持たせて、N=6以外にも実行できるようにしたため、ちょっと難しくなってしまった、というところが1点目ですね。あと、当たり判定するためのデータ構造をどうしようか、と、迷いながら進めていたというところが2点目ですね。先に仕様を固めてからつくればよかったですね。小さいプログラムだからと油断し過ぎました。

 作業記憶と注意力、鍛えましょう!!!

Python tkinter GUI プログラミング DataFrameを表示

 今日も見に来てくださって、ありがとうございます。石川さんです。

 ここのところ、データサイエンス入門の勉強中です。データサイエンスで利用されるライブラリのpandasにあるDataFrameクラス、データを操作するのにいろいろな機能があって、とても便利です。ぼくは普段SQLを使っているので必要性はまったくないのですけど、Pythonでプログラミングするのに、覚えておくとよいですね。

 tkinterも最近の学習テーマなので、組み合わせて、DataFrameの内容をtkinterを使って表示するにはどうしたらいいのかな、と、ちょっと作ってみました。

できあがりイメージ

データフレームの内容を表示する

ソースコード

import tkinter as tk
import numpy as np
from pandas import DataFrame

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Display DataFrame")
        
        self.df = DataFrame(np.arange(20).reshape(4,5))
        for r in range(4):
            for c in range(5):
                e = tk.Entry(self)
                e.insert(0,self.df.iloc[r,c])
                e.grid(row=r,column=c)
                e.bind("<KeyPress>",lambda event, row=r, column=c: self.change(event,row,column))

    def change(self,event,row,column):
        value = event.widget.get()
        self.df.iloc[row,column] = value
        print(self.df)

if __name__ == '__main__':
    app = App()
    app.mainloop()

説明

 まずは、10行目でDataFrameのインスタンスを作成しています。データは、numpyのarange()を使って、0から20個の要素をつくって、reshape()で4行5列に変更しています。

 forループを二重にしてEntryを作成しています。作成するごとに、insert()を使ってデータをセットしています。データの取得は、iloc[r,c]を使います。gridで行と列を指定して配置します。bind()で<KeyPress>イベント、キーが押されたときにchange()メソッドを呼び出すようにバインドしています。

まとめ

 表をつくって表示するだけなら、ほんの数行でできてしまいました。Pythonって、すごいですねぇ♪

Python tkinter GUI プログラミング フォントについて2

 今日も見に来てくださって、ありがとうございます。石川さんです。

 またしても、tkinterのフォントについて調べています。先日は、事前定義されたフォントの一覧を出力してみました。本家のページには直接修正せずに、コピーして使ってね、と書いてありました。直接修正する、ということがどのようなことを指すのかちょっとわかりにくかったので、実際に試してみました。

最終イメージ

フォントサイズを変更して実験してみました。

ソースコード

import tkinter as tk
import tkinter.font as tkFont

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Font test")
        self.geometry("600x180")
        
        font = tkFont.nametofont("TkFixedFont")
        print(font,font["size"])
        tk.Label(self,text="初期値をセット",font=font).pack()
        font["size"] = 30
        tk.Label(self,text="サイズを大きく変更したラベル",font=font).pack()
        font = tkFont.nametofont("TkFixedFont")
        print(font,font["size"])
        tk.Label(self,text="再度フォントを取得してセット",font=font).pack()
        font = tkFont.nametofont("TkFixedFont").copy()
        print(font,font["size"])
        font["size"] = 10
        tk.Label(self,text="コピーしたフォントをセット",font=font).pack()
        font = tkFont.nametofont("TkFixedFont")
        print(font,font["size"])

        print(tkFont.names())

if __name__ == "__main__":
    app = App()
    app.mainloop()

説明

 先日出力したフォント名一覧の中から、TkFixedFontを選んでテストしてみました。固定長のフォントですね。ぼくの環境はWindows10ですので、これには「MS ゴシック」が割り当てられていました。まずは、10行目で、この名称からフォントを取得します。フォントとサイズは、printしてみた結果、TkFixedFontと10でした。

 で、まずは、12行目でこのフォントを使って、ラベルを作成してみます。これは、サイズ10のラベルを出力することを期待していました。そして、その後、フォントサイズを30にセットして、変更したフォントを使ってラベルを作成します。当然これはサイズ30のラベルを出力することを期待しています。

 次に、もう一度同じフォントですが、15行目で名称からフォントを取得しました。このようにした時に、最初のサイズ10のフォントを取得するのか、セットしたサイズ30のフォントが取得できるのかを調べたかったのです。期待としては、サイズ10のフォントが取得できるのかな、と、思っていました。

 ここまででいったん出力してみたところ、なんと、すべて同じサイズ30という結果になりました。こんなイメージです。

フォントはすべてサイズ30でした。

 結果から、TkFixedFontという名前のフォントは、すべて同じ設定で出力されているようです。別途設定してみたところ、すべて同時に変更されることを確認しました。

 これらの定義済みのフォントはOSの設定値から取得していて、OSの設定値が変更されるとtkinterが自動で取得している、ということでした。ユーザが値を設定するとおそらくその自動で取得することができなくなってしまうから、変更しないように、ということだったのですね。

 と、いうことで、コピーはどうするのでしょうか、と、やってみたのが18行目です。フォントを取得して、.copy()と記載しただけです。サイズを変更してセットしてみると、見事、最後の一つだけサイズが10にセットされたラベルが作成できました。フォントの名前は実行するたびに替わっていて、最初に確認したときはfont7で、1ずつインクリメントしていました。

 最後に、この新しい名前のフォントが登録されているか、確認してみましたが、入っていませんでした。

まとめ

 定義済みのフォントを変更したいときは、コピーしてから値を変更して利用する、ということですね。

Python tkinter GUI プログラミング フォントについて

 今日も見に来てくださって、ありがとうございます。石川さんです。

 先日、フォントのサイズを変更しているうちにフォントが気になってきたのでちょっとまとめていきたいと思います。

できあがりイメージ

デフォルトで定義されているフォント一覧

 左から、定義されているフォント名、フォントファミリー名、サイズ、サンプルです。

ソースコード

import tkinter as tk
import tkinter.font as tkFont
from unicodedata import east_asian_width

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Font examples")
        self.geometry("800x400")
        
        for font_name in tkFont.names():
            font = tkFont.nametofont(font_name)
            frame = tk.Frame(self)
            fw = 13 - len([c for c in font['family'] if east_asian_width(c) in 'AFW'])
            tk.Label(frame,text=f"{font_name:18} {font['family']:{fw}s} {font['size']:2d} ",
                     font="TkFixedFont").pack(side=tk.LEFT)
            tk.Label(frame,text="This is ascii text. 日本語だとこんな風に出力されます。",
                     font=font_name).pack(side=tk.LEFT)
            frame.pack(side=tk.TOP, anchor=tk.W)
            
if __name__ == "__main__":
    app = App()
    app.mainloop()

解説

 フォントは、tkinterの中のfontモジュールです。最初にtkFontという文字列でインポートしています。

 定義済みのフォントの名前一覧は、11行目のtkFont.names()で取得できます。これをforループで出力していきます。12行目、tkFont.nametofont()でフォント名からフォントを取得しています。

 次の行からは、フレームをつくって、左側のラベルに定義されたフォントの名前、フォントファミリ名、フォントサイズを出力します。右側のラベルにそのフォントを指定したテキストを出力しています。このフレームを定義された数分作成していきます。

 今回ちょっと引っかかったのは、フォントファミリー名に「MS ゴシック」があったのですけど、フォント名を出力するのは最大13文字でいいでしょうと、決めて、f”{font[‘family’]:13}”の指定にしていたのですけど、マルチバイト文字は、全角なので幅が正しく出力されないのですね。こんな風に出ていました。

「MS ゴシック」が全角のため、フォントサイズがうまく並ばなかった

 と、いうことで全角半角をうまく出力する方法はありませんかねぇ、と、探しまわって見つけたのがこのページです。これを参考にさせていただいて、出力幅を決めるのに、14行目のように修正いたしました。このunicodedataモジュールにある、east_asian_width(c)は全角か半角かを戻す関数で、AFWが戻ってきたときは全角だそうです。なので、半角だと最大13文字のところ、全角文字のときは一文字分不要になるということで全角の文字数を引き算しています。フォーマット文字列で幅を指定しているところも{}で出力できるかな、と、こんな風に変数fwを指定するように試しにやってみたところ「f"{font['family']:{fw}s}"」、できました!pythonスゴイです!!あ、でも、最初からこのオプションがあった方が、もっとスゴイですね!

 この定義済みのフォントは、用途が決まっているようです。本家のページに記載がありました。また、OSによって、出力される項目に少し違いが出てくるようですね。これらのフォントは、Tkがシステムの変更を自動的に反映させるので、直接変更するのではなく、コピーしてから使うように、と、いうことですね。

まとめ

 フォントについて調べ始めましたが、ちょっと始めただけで、いろいろと知っていないといけないことがあって、とても奥が深いですね。ただ、そんなに変わったことをしない限りは定義済みのフォントを使用するのがよさそうですね。

Python tkinter GUI プログラミング Canvas フォントサイズの変更

 今日も見に来てくださって、ありがとうございます。石川さんです。しつこくCanvasについてやってます。

 前回、拡大縮小ができたのですけど、ふと、文字はどうなるのかなぁ、と、試してみたところ、ぜんぜん追従してくれませんで、大きさがちっとも変わりませんでした。スクリプトもどんどん大きくなってきたので、別のスクリプトで実験したいと思います。

出来上がりイメージ

実行してすぐのフォントの大きさ
しばらくしてからのフォントの大きさ

 はい、放っておくと、どんどん字が大きくなっていきます。

ソースコード

import tkinter as tk
import tkinter.font as tkFont

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Change Canvas text size")
        self.geometry("400x200")
        
        self.canvas = tk.Canvas(self, background="white")
        self.canvas.pack(fill=tk.BOTH)
        
        self.text = self.canvas.create_text(200,100,text="Change?")
        
        self.resize_font()
    
    def resize_font(self):
        fontName = self.canvas.itemcget(self.text, "font")
        font = tkFont.nametofont(fontName)
        size = font["size"]
        font["size"] = size+1
        self.canvas.itemconfigure(self.text,font=font)
        self.after(500, self.resize_font)

if __name__ == "__main__":
    app = App()
    app.mainloop()

説明

 ポイントは、resize_font()メソッドです。__init__()メソッドの最後で呼び出しますが、呼び出されて処理をした後に、0.5秒後に自分自身を呼び出すように指定してあります。そう、23行目のself.after(500, self.resize_font)、この部分です。

 キャンバスの中に作成した項目の属性を取得するためには、キャンバスのitemcget()を使います。18行目でこれを使って、フォント名を取得しています。

 19行目では、tkinterfontモジュールの中のnametofont()ファンクションを使って、フォントを取得しています。20行目では、そのフォントのsizeを取り出して、21行目で+1した値をセットし直しています。

 22行目、キャンバス中に作成した項目の属性をセットするために、itemconfigure()を使います。これで、もとのフォントサイズを少しずつ大きくしてすることに成功しました。

まとめ

 フォントのサイズはポイントで指定しているので、前回の拡大縮小に合わせるのにはちょっと工夫が必要そうですね。マイナス値で指定すると、ピクセルで指定できるらしいですが、、、そのあたりは、もうちょっと調査してみます。