Python tkinter GUI プログラミング Canvas scale

 今日も見に来てくださって、ありがとうございます。石川さんです。まだまだCanvasについて、調べていきたいと思います。

 先日までは、Canvasの移動ができるようになりました。今回は、前回の予言通り、拡大、縮小にチャレンジしたいと思います。イベントにMouseWheelというのがありましたので、これを使いたいと思います。Excelで調べてみたら、ホイールを普通に回すと上下のスクロール、Ctrlキーを押してホイールを回すと拡大縮小、Ctrl+Shiftキーを押してホイールをま指すと左右にスクロールしましたので、こちらに合わせてみたいと思います。

ソースコード

 今回もイメージは変わらなかったので、ソースコードのみ掲載したいと思います。

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Canvas move test")
        self.geometry("400x200")
        
        sx = tk.Scrollbar(self, orient=tk.HORIZONTAL)
        sy = tk.Scrollbar(self, orient=tk.VERTICAL)
        c = tk.Canvas(self, background="white",xscrollcommand=sx.set,yscrollcommand=sy.set)
        sx.config(command=c.xview)
        sy.config(command=c.yview)
        self.info = tk.Label(self)
        self.info.grid(row=2,columnspan=2)
        c.grid(row=0, column=0, sticky=tk.NSEW)
        sx.grid(row=1, column=0, sticky=tk.EW)
        sy.grid(row=0, column=1, sticky=tk.NS)
        
        self.canvas = c 
        self.start = None
        c.bind("<Motion>",self.move)
        c.bind("<ButtonPress>",self.button_press)
        c.bind("<ButtonRelease>",self.button_release)
        c.bind("<MouseWheel>",self.mouse_wheel)
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)
        self.bind("<Configure>", self.resize)
        
        c.create_rectangle(40,40,60,60)


    def move(self, event):
        x = self.canvas.canvasx(event.x)
        y = self.canvas.canvasy(event.y)
        self.info.configure(text=f"mouse position = ({event.x}, {event.y}) ({x},{y})")
        if self.start and self.item:
            original_x, original_y = self.start
            self.canvas.move(self.item,x - original_x,y - original_y)
            self.start = x, y
            self.resize(event)
        elif event.state == 256: # Button1
            self.canvas.scan_dragto(event.x, event.y, gain=1)
    
    def button_press(self, event):
        x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
        self.start = x, y
        self.item = self.canvas.find_overlapping(x-10,y-10,x+10,y+10)
        if self.item:
            self.item = self.item[0]
        else:
            self.canvas.scan_mark(event.x, event.y)
        if event.num == 3:
            self.item = self.canvas.create_rectangle(x-10,y-10,x+10,y+10)

    def button_release(self, event):
        self.start = None

    def resize(self, event):
        region = self.canvas.bbox(tk.ALL)
        if region[0] > 0:
            region = (0, *region[1:])
        if region[1] > 0:
            region = (region[0], 0, *region[2:])
        self.canvas.configure(scrollregion=region)

    def mouse_wheel(self, event):
        if event.state == 5: # Shift|Control
            self.canvas.scan_mark(event.x, event.y)
            self.canvas.scan_dragto(event.x + event.delta // 120, event.y, gain=10)
        elif event.state == 4: # Control
            scale = 1 + event.delta / 1200
            self.canvas.scale(tk.ALL,event.x, event.y, scale, scale)
        elif event.state == 1: # Shift
            pass
        elif event.state == 0: # None
            self.canvas.scan_mark(event.x, event.y)
            self.canvas.scan_dragto(event.x, event.y + event.delta // 120, gain=10)
        self.resize(event)

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

解説

 まずは、ホイールのイベントをバインドします。25行目にc.bind("<MouseWheel>",self.mouse_wheel)を追加しました。これによって、ホイールが回されたときにmouse_wheelが呼び出されるようになります。

 それぞれコントロールキーを押されたとき、シフトキーを押されたとき、両方押されたときの実行結果をeventprintしてみました。

<MouseWheel event delta=-120 x=244 y=110>
<MouseWheel event state=Control delta=-120 x=244 y=110>
<MouseWheel event state=Shift delta=-120 x=244 y=110>
<MouseWheel event state=Shift|Control delta=-120 x=244 y=110>

 このままではstateの値がわからないので、別途event.stateprintしてみたところ、以下のようになっていました。

  • Shift=1
  • Ctrl=4
  • Shift+Ctrl=5

 deltaの値は、ホイールが下に回されると、-120、上に回されると120がセットされてきました。これらの値と先日使った、scan_markscan_dragtoで上下スクロールを実装、scaleを使って拡大縮小を実装しました。拡大縮小のscaleは、このページを参考にさせていただきました。

 あとは、resizeしたときに左上が(0,0)の位置じゃなくなってしまうのは、どうなのかなぁ、と、思ったので、resizeを変更してみました。もしかしたら、もっといい方法があるかも知れませんが、とりあえず満足いく動作になりました。

まとめ

 今回は、割合すんなりと、ホイールを使った拡大縮小ができるようになりました。拡大縮小したときに、箱を作ると、大きさの違う箱になってしまうので、拡大率、縮小率を保持して箱を作るときに参照する必要がありそうですね。

Python tkinter GUI プログラミング Canvas move2

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

 前回、Canvasにスクロールバーを付けて、描画したものを移動する、というのをやりました。今回は、その続きです。ちょっと改善して、機能追加しました。見た目は変わらなかったので、ソースコードだけ付けておきます。

ソースコード

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Canvas move test")
        self.geometry("400x200")
        
        sx = tk.Scrollbar(self, orient=tk.HORIZONTAL)
        sy = tk.Scrollbar(self, orient=tk.VERTICAL)
        c = tk.Canvas(self, background="white",xscrollcommand=sx.set,yscrollcommand=sy.set)
        sx.config(command=c.xview)
        sy.config(command=c.yview)
        self.info = tk.Label(self)
        self.info.grid(row=2,columnspan=2)
        c.grid(row=0, column=0, sticky=tk.NSEW)
        sx.grid(row=1, column=0, sticky=tk.EW)
        sy.grid(row=0, column=1, sticky=tk.NS)
        
        self.canvas = c 
        self.start = None
        c.bind("<Motion>",self.move)
        c.bind("<ButtonPress>",self.button_press)
        c.bind("<ButtonRelease>",self.button_release)
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)
        self.bind("<Configure>", self.resize)
        
        c.create_rectangle(40,40,60,60)

    def move(self, event):
        x = self.canvas.canvasx(event.x)
        y = self.canvas.canvasy(event.y)
        self.info.configure(text=f"mouse position = ({event.x}, {event.y}) ({x},{y})")
        if self.start and self.item:
            original_x, original_y = self.start
            self.canvas.move(self.item,x - original_x,y - original_y)
            self.start = x, y
            self.resize(event)
        elif event.state == 256: # Button1
            self.canvas.scan_dragto(event.x, event.y, gain=1)
    
    def button_press(self, event):
        x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
        self.start = x, y
        self.item = self.canvas.find_overlapping(x-10,y-10,x+10,y+10)
        if self.item:
            self.item = self.item[0]
        else:
            self.canvas.scan_mark(event.x, event.y)
        if event.num == 3:
            self.item = self.canvas.create_rectangle(x-10,y-10,x+10,y+10)
            

    def button_release(self, event):
        self.start = None

    def resize(self, event):
        region = self.canvas.bbox(tk.ALL)
        self.canvas.configure(scrollregion=region)

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

変更点の説明

 前回のバージョンだと、作られた四角形をドラッグして表示外へ移動したときに、スクロールバーがうまく更新されていませんでした。しばらくして、何かのタイミングでちゃんと更新されるのですが、うれしくないなぁ、ということでここを修正することにしました。これは、今回のソースコードで言うと、39行目のself.resize(event)の呼び出しです。このメソッドはキャンバス上のすべての項目が入る領域を取得して、その領域をscrollregionへセットする、ということをしています。これで、スクロールバーがうまく更新されるようになりました。

 次に、キャンバス上の矩形を移動させるのではなくて、キャンバスを移動させたい、と思いましたので、それを調べてみました。いろいろと探しまわって、ここにやっとだとりつきました。50行目のself.canvas.scan_mark(event.x, event.y)で、開始をセットして、41行目のself.canvas.scan_dragto(event.x, event.y, gain=1)を使ってキャンバスを移動します。もともとは、クリックしたときに四角形を書いていたので、51行目のように、event.numをチェックすることで右クリックのときに四角形を書くように変更しました。event.numは、左クリックが1、右クリックが3、左右の両方を同時クリックすると2が割り当てられていました。

 あと、event.state == 256のときにscan_dragtoを実行するようにしました。このstateは、'Shift', 'Lock', 'Control','Mod1', 'Mod2', 'Mod3', 'Mod4', 'Mod5','Button1', 'Button2', 'Button3', 'Button4', 'Button5'の順で、2**0、2**1、2**2、、、となっていて、Button1は2**8=256でした、というのはtkinterモジュールの__init__.pyのソースコードを読んだのでわかりましたが、ちょっと追加されたときに対応できていないので、なんとかしたいところです。どなたか、もっとよいやり方を知っている方、教えてくださ~い♪

 ちなみに、scan_dragtoに指定するgainは実際の移動に対して何倍移動するか、というのが指定可能です。シフトを押しながらマウスを動かしたときはたくさん動かす、といったときに使えるようですね。

まとめ

 スクロールバーに移動したものを正しく反映させて、キャンバスを移動するだけで、ずいぶんと時間がかかってしまいました。もっと簡単にできるかな、と、思っていたのですけど意外と難しかったです。次回は、拡大、縮小かなぁ。

Python tkinter GUI プログラミング Canvas move

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

 以前Canvasに書いたものを動かす、というのをやったので今回はキャンバスにスクロールバーを付けてみようとやってみました。その中で気づいたことについて書いていきたいと思います。

出来上がりイメージ

出来上がりイメージ

 何もないところをクリックすると、四角形を描画します。四角形はマウスで動かすことができます。

ソースコード

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Canvas move test")
        self.geometry("400x200")
        
        sx = tk.Scrollbar(self, orient=tk.HORIZONTAL)
        sy = tk.Scrollbar(self, orient=tk.VERTICAL)
        c = tk.Canvas(self, background="white",xscrollcommand=sx.set,yscrollcommand=sy.set)
        sx.config(command=c.xview)
        sy.config(command=c.yview)
        self.info = tk.Label(self)
        self.info.grid(row=2,columnspan=2)
        c.grid(row=0, column=0, sticky=tk.NSEW)
        sx.grid(row=1, column=0, sticky=tk.EW)
        sy.grid(row=0, column=1, sticky=tk.NS)
        
        self.canvas = c 
        self.start = None
        c.bind("<Motion>",self.move)
        c.bind("<ButtonPress>",self.button_press)
        c.bind("<ButtonRelease>",self.button_release)
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)
        self.bind("<Configure>", self.resize)
        
        c.create_rectangle(40,40,60,60)

    def move(self, event):
        x = self.canvas.canvasx(event.x)
        y = self.canvas.canvasy(event.y)
        self.info.configure(text=f"mouse position = ({event.x}, {event.y}) ({x},{y})")
        if self.start and self.item:
            original_x, original_y = self.start
            self.canvas.move(self.item,x - original_x,y - original_y)
            self.start = x, y
    
    def button_press(self, event):
        x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
        self.start = x, y
        self.item = self.canvas.find_overlapping(x-10,y-10,x+10,y+10)
        if self.item:
            self.item = self.item[0]
        else:
            self.item = self.canvas.create_rectangle(x-10,y-10,x+10,y+10)

    def button_release(self, event):
        self.start = None

    def resize(self, event):
        region = self.canvas.bbox(tk.ALL)
        self.canvas.configure(scrollregion=region)
        

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

説明

 ウィンドウを作成するところまではいつも通り、tk.Tkを継承したクラスでバッチリですね。タイトルとウィンドウの大きさをセットしています。

 9、10行目でスクロールバーを作成、11行目でキャンバスを作っています。このとき、作成したスクロールバーを指定しています。9行目のスクロールバーにはtk.HORIZONTALで水平を指定します。10行目のスクロールバーはtk.VERTICALで垂直を指定しています。12、13行で、はconfigでスクロールバーのコマンドをセットしてこれでスクロールバーの実装完了です。

 14行目のラベルはマウスポインターの座標を表示するために追加しました。ウィジェットを作成した後は、gridで配置しました。

 イベントとしては、23~25行目で"<Motion>""<ButtonPress>""<ButtonRelease>"、27行目で"<Configure>"をセットしています。それぞれ、マウスが動いたときのイベントにmoveメソッド、マウスのボタンが押されたときにbutton_press、離されたときにbutton_release、そして、最後は、ウィンドウの大きさが変更されたときのイベントにresizeを割り当てています。

 25、26行目のrowconfigure()メソッドとcolumnconfigure()メソッドを呼び出して、最初の行と列のサイズを可変にしています。weightオプションは、可変にした時に他の行やカラムとスペースを分配する場合の重みを指定しますが、今回は他に分配するスペースはありませんので1を指定しました。

 29行目、矩形を描画しています。(40,40)が左上の角で、(60,60)が右下の角です。

 今回のポイントは32、33行目のself.canvas.canvasx(event.x)y = self.canvas.canvasy(event.y)の部分です。実は、ぼくは最初、eventxyは、キャンバスに描画したときのxyと完全に一致していると思っていました。実は異なる場合があるのです。上記のイメージ左上の矩形、見た目は、(0,0)から描画されているように見えますが、29行目で描画したとおり、(40,40)から描画されているのでした。よくよく考えてみると描画した点とマウスの指す点は、同じ場所でも異なってくるのは当たり前でしたね。特にスクロールバーを付けたら同じにならなくなるのは分かることでした。eventで取得できるマウスの座標は、見えている左上の角からの座標で、描画されている座標はCanvasの左上からの座標になっています。そう、スクロールした分だけずれてくることがあるのでした。このずれを解消するために、canvasxcanvasyが利用できるわけです。

まとめ

 Canvasを使うときは、表示系の座標と描画系の座標の二系統を気にする必要がありますね。

Python tkinter GUI プログラミング Variable

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

 今日は、tkinterで用意されているVariableの挙動が気になったのでちょっと調べてみました。4種類の型を扱えるようにVariableを継承した以下のクラスが定義されていました。日付型はありませんでしたね。

  • StringVar 文字列(str)を扱います
  • IntVar 整数(int)を扱います
  • DoubleVar 浮動小数点数(float)を扱います
  • BooleanVar 真理値(bool)を扱います

 これまでのスクリプトでもこっそり使っていたかも知れませんが、ざっくり言うと、このVariableは、ウィジェットに割り当てることのできる変数です。これまでの例で行くと、メニューのラジオボタンでオプション値を保持するために使いました。あのときは、チェックが一か所つくだけだったので、あんまり気にしていなかったのですけど、一つの値を複数の場所で表示できるのか、という点が気になりましてちょっと動きを確認することにしました。

出来上がりイメージ

 こんな感じです。

変数のテスト 出来上がりイメージ

 ラベル、エントリー、ボタン、エントリーを作ってあります。ボタンを押すと現在の値に”test “を追加します。

ソースコード

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Variable Test")
        self.geometry("250x100")
        
        self.str_val = tk.StringVar()
        
        self.label1 = tk.Label(self, textvariable=self.str_val)
        self.entry1 = tk.Entry(self, textvariable=self.str_val)
        self.btn = tk.Button(self, text="Push me!", command=self.pushed)
        self.entry2 = tk.Entry(self, textvariable=self.str_val)
        
        self.label1.pack()
        self.entry1.pack()
        self.btn.pack()
        self.entry2.pack()

    def pushed(self):
        self.str_val.set(self.str_val.get()+"test ")

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

解説

 ウィンドウを作るところはいつも通り、Tkを継承したクラスを作成しています。タイトルをセットして、ウィンドウのサイズを決めるところまではだいたいいつも通りです。

 9行目、StringVarで変数を定義しています。

 11~14行目で、ラベル、エントリー、ボタン、エントリーの順番に作成しています。ラベルとエントリーはtextvariableパラメータで変数を割り当てています。この割り当てにより、エントリーで変更した値が即座にself.str_valに反映されます。self.str_valに反映された値は、他のラベルとエントリーに反映されます。ボタンは、self.str_val"test "を追加するコマンド、self.pushedを作成して、これをcommandに割り当てました。

まとめ

 期待はしていましたが、期待通りに値は即座に反映されました。ウィジェットと変数のやり取りの部分は、何にも気にしなくても自動的に表示が更新されるようになりました!簡単ですね♪

Python tkinter GUI プログラミング CanvaのTextについて

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

 Canvasにテキストを書いたときに、その大きさや、座標はどうなるのかな、と、ちょっと気になったので調べてみました。こちらのサイトを参考にさせていただきました。

出力イメージ

 出力イメージはこんな感じになります。

tkinterのCanvasにてテキストを出力したところ

作成したスクリプト

 今回作成したスクリプトは以下のようになりました。

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Canvas Text")
        self.geometry("400x200")
        
        c = tk.Canvas(self, bg="white")
        c.pack(fill=tk.BOTH)
        
        t1 = c.create_text(70, 30, text="1st text", font=("",24))
        c.create_rectangle(c.bbox(t1))
        x0, y0, x1, y1 = c.bbox(t1)
        c.create_line(x1, y1, x1 + 30, y1 + 30, arrow=tk.FIRST)
        description = "bboxは、テキストを囲む箱の座標を戻します。"
        c.create_text(x1 + 30, y1 + 30, text=description, anchor=tk.W)
        x, y = c.coords(t1)
        c.create_line(x, y, x + 70, y + 70, arrow=tk.FIRST, fill="red")
        description = "coordsは中心点を戻します。"
        c.create_text(x + 70, y + 70, text=description, anchor=tk.NW, font=("",9))
        width = tk.font.Font(c,font=("",9)).measure(description)
        height = tk.font.Font(c,font=("",9)).metrics("linespace")
        c.create_line(x + 70, y + 70 + height, x + 70 + width, y + 70 + height)
        
if __name__ == "__main__":
    app = App()
    app.mainloop()

解説

 もろもろのアプリケーションウィンドウをつくるところは端折って、まずは9行目でキャンバスを作成しています。作成時のbg=”white”で背景色を白に、packのfill=tk.BOTHでウィンドウサイズに合わせます。

 12行目で”1st text”と最初のテキストを作成しています。座標にx=70、y=30を指定しています。ちょっと大きめになるようにフォントのサイズを24と指定しています。フォント名を指定することで変更することもできますが、初期値のままにしておきたかったので、””を指定しています。

 13行目のcreate_rectangleを使って、矩形を描画しています。指定する座標はbboxで取得していますが、bboxはテキストを囲む矩形の左上の座標と右下の座標を取得します。14行目では説明用の線を引くために、もう一度bboxを呼び出して座標をアンパックして取り出しています。ぼくの環境では、それぞれ、(17, 14, 124, 47)がセットされていました。

 15行目で矢印付きの直線を斜め下に弾いています。arrow=tk.FIRSTで開始座標が矢印になっています。

 18行目でcoordsを使っていますが、これはテキストを作成するときに指定した座標を戻します。この場合はテキストの中心点になっています。これは、テキスト作成時にanchorを指定しなかったので、デフォルト値のtk.CENTERが指定されたためです。他にどこを指定するかというのは、こちらが参考になりました。

 ちなみにぼくの環境ではこのcoordsの戻り値、テキスト作成時の座標に70と30を指定した結果、(70.0、30.0)になっていました。coordsは小数点以下が有効になっているようです。試しにcreate_textの座標を70.5とか、70.9999とかに設定したら、その値をきちんと保持していることが確認できました。有効桁数がどれくらいなのか、どうやってこの座標値を扱っているのか、ちょっぴり気になります。

 22行目は、テキスト出力の横幅をどうやって計算すればいいのか、というのを調べた結果です。これはあちこち探しまわって、結局ここを参考にしたと思います。テキストに下線を引いてちゃんと幅が一致していることを確認しました。最初はfont=(“”,9)というのを指定していなかったのですが、そうすると、最初にcreate_textした時のサイズ、24がなぜか取得できてしまったので、テキストとともにわざわざ設定することにしました。今回はとりあえず幅が求まって満足したので、なぜそんなことになるのかという原因までは調査しませんでした。

 bboxがあるのでいったん出力してしまえばテキストの描画幅は座標から計算できるのですけど、幅をもとに位置決めをしたい要望があったので、先に計算して求める方法を探し出しました。

まとめ

 これでキャンバス上にテキストと線を自由自在にひくことができるようになりました。いや、なったはずです!(笑)

Python tkinter GUI プログラミング ttk Notebookサンプル

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

 今回は、tkinterの中に定義されている、ttkのNotebookについて、ちょっと調べてみました。

出来上がりイメージ

ttk Notebookサンプル

 簡単に言うとタブ付きの切り替えですね。

ソースコード

 ソースコードは、以下のようになりました。

import tkinter as tk
import tkinter.ttk as ttk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("ttk Notebook サンプル")
        properties = {
            "全般":["タイトル","タグ","メニュー名","表示可能","使用可能","コントロールメニュー","最大化ボタン","最小化ボタン","クライアントエッジ"],
            "スクロール":["水平スクロールバー","垂直スクロールバー","行の単位","カラムの単位","1ページのカラム数","1ページの行数"],
            "ツールバー":["ツールバーの表示","ツールバーの配置","ツールバーX","ツールバーY","ツールバーの幅","ツールバーの高さ"],
            "その他":["X","Y","幅","高さ","ポインタ"],
        }
        self.notebook = ttk.Notebook(width=400, height=200)
        for key, value in properties.items():
            frame = ttk.Frame(self.notebook)
            self.notebook.add(frame, text=key, sticky=tk.NE+tk.SW)
            for text in value:
                ttk.Label(frame, text=text).pack(anchor=tk.W)
        self.notebook.pack()

        self.button = ttk.Button(self, text="Close", command=self.destroy)
        self.button.pack()

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

解説

 割と簡単にできました。まず、Notebookを作ります。そして、FrameをNotebookの子供として作成して、addするだけですね。Frame以外にもCanvasやLabelなどWidgetを指定できるようです。

まとめ

 タブ付きのエディタを作りたい、と、思っているのですけど、使えそうですね!

Python tkinter GUI プログラミング ドラッグ・アンド・ドロップ

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

 先日、tkinterモジュールのソースコードを眺めていたら、dnd.pyというファイルがありましてちょっと、おお、と思ったので記録しておきます。dndというからにはドラッグ・アンド・ドロップでしょ。

動作確認

 ぼくは、Anacondaを使っているので、C:\ProgramData\Anaconda3\Lib\tkinterにモジュールがあって、そこにこのdnd.pyファイルがあります。ソースを見ると、最後の方にありました、テスト用に直接実行できるコードです。

if __name__ == '__main__':
    test()

 さっそく実行してみました。

Dndテスト結果

ソースコード

 ソースコードを追加したかったのですが、みなさんのフォルダにもあるはずなのと、(ぼくの環境は、C:\ProgramData\Anaconda3\Lib\tkinter\dnd.pyです。)、著作権的にも問題があると思いますので、今回はそちらをご覧ください。

解説

 ソースコードは下から見ていくとよさそうですね。まずは、ルートのTkをインスタンス化してから、その後、Testerクラスを使ってウィンドウを三つ作成しています。この中にはそれぞれCanvasが作られてありますね。そして、次にIconクラスのインスタンスを三つ作って、それぞれそのウィンドウのCanvasattachを実行する、という流れですね。attachでは、引数で受け取ったCanvasLabelをつくって、それからマウスの第一ボタンが押されたときの処理としてpressが登録されています。

 pressでは、dndモジュールの最初に定義されているdnd_startが呼び出されていて、ここでDndHandlerによってラップされたインスタンスが作成されます。DndHandlerの初期化の処理で、マウスのボタンがリリースされたときのイベントの処理にon_releaseが登録され、<Motion>イベントの処理にon_motionが登録されています。要するにマウスが動くたびにon_motionが呼び出されて、マウスのボタンを離したときにon_releaseが呼び出されるようにしました、ということですね。あ、あと、カーソルが"hand2"に変更されているように見えます。

 このマウスが動くたびに呼び出されるon_motionは、処理中にターゲットのCanvasが変更されていない間はdnd_motionを呼び出すことでキャンバス内のラベルを移動させています。ターゲットのCanvasが変更されたときは、dnd_leavednd_enterがそれぞれ変更前のCanvasと変更後のCanvasで呼び出されて、ドラッグ・アンド・ドロップ実現しています。

 最後にマウスのボタンが離されたときに、イベントが削除されたりカーソルを元に戻したりしたあとに、dnd_commitを呼び出して終了処理をしています。

まとめ

 ドラッグアンドドロップは、実装するのに、こんなに複雑なことをしないといけないのですねぇ。今回はざっと眺めただけなのでよいですけど、自分で同じ機能を実現するのは、かなり苦労しそうです。

Python tkinter GUI プログラミング メニューについて

 今日も見に来てくださって、ありがとうございます。石川さんです。プログラミングマニアじゃありませんよ。

 だけど、今回もtkinterのメニューの作り方について、まとめてみました。だから、マニアじゃないんだってば。

出来上がりイメージ

全体感
Fileメニュー
Optionsメニュー

ソースコード

import tkinter as tk
import tkinter.messagebox as mb

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Menu sample")
        self.geometry("400x50")
        
        menu = tk.Menu(self)
        file = tk.Menu(menu, tearoff=0)
        edit = tk.Menu(menu, tearoff=0)
        option = tk.Menu(menu, tearoff=0)
        
        file.add_command(label="New")
        file.add_command(label="Open")
        file.add_separator()
        file.add_command(label="Save")
        file.add_command(label="Save all")
        file.add_command(label="Save as...")
        file.add_separator()
        file.add_command(label="Quit", command=self.destroy)
        
        edit.add_command(label="Undo")
        edit.add_command(label="Redo")
        edit.add_command(label="Cut")
        edit.add_command(label="Copy")
        edit.add_command(label="Paste")
        edit.add_command(label="Select All")
        
        self.check = tk.BooleanVar(name="check")
        self.check.trace("w", self.checked)
        option.add_checkbutton(label="Check", onvalue=True, offvalue=False, variable=self.check)
        option.add_separator()
        self.radio = tk.StringVar(name="radio",value="A")
        self.radio.trace("w", self.selected)
        option.add_radiobutton(label="A", value="A", variable=self.radio)
        option.add_radiobutton(label="B", value="B", variable=self.radio)
        option.add_radiobutton(label="C", value="C", variable=self.radio)
        
        menu.add_cascade(label="File", menu=file)
        menu.add_cascade(label="Edit", menu=edit)
        menu.add_cascade(label="Options", menu=option)
        menu.add_command(label="Help", command=lambda:mb.showinfo("Help","Help me!"))

        self.config(menu=menu)
        
    def checked(self, *args):
        print(self.check.get(), *args)
        
    def selected(self, *args):
        print(self.radio.get(), *args)

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

解説

 ソースコードを見ればそのままなので、特に解説は必要ないような気がしてきました。が、気を取り直して、menuの変数にトップレベルのメニューを追加しました。以降は、このmenuにメニューを追加していきます。今回は、fileeditoptionをそのメニューの中のメニューとして作成しました。メニューの区切りはadd_separator()を実行することで追加できます。add_cascadeで中にさらに展開するメニューを追加することができます。そして、最後にhelpを追加してみました。

 メニュー追加時のtearoffは、初期値が有効になっています。そのオプションを有効にしておくと、サブメニューを取り外すことができて、独立したウィンドウで好きなところへ配置できるようになっています。オプションを無効にする必要はないのですが、最近はこのようにデザインされているメニューがあまりないので、無効にしておくとよいと思います。

 optionsには、create_checkbuttonでチェック可能なメニューと、add_radiobuttonで選択可能なメニューを追加しました。セットされた値はBooleanVarStringVarを利用して監視しています。これらの値を保持するためのクラスはtkinterで用意されているもので、BooleanVarStringVar以外にも、IntVarDoubleVarがあります。今回は書き込み時(“w”)のコールバックを登録して、書き込み時にprintを実行するようにしました。

 あ、言わずもがなですが、コマンドはcommandオプションで指定できます。

まとめ

 簡単なメニューをつくるのは特に難しくありませんね。アクセラレータやショートカットなんかを追加したい、ということになると環境の違いを考慮する必要も出てきて、ちょっと難しくなるようですね。

Python tkinter GUI プログラミング Entryその3

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

 自分でもしつこいなぁ、とは思いますが、Entryで日付を入力するためのウィジェットをつくる、パート3です。前回つくったのは、ちょっと納得できなかったのですよねぇ。ま、今回も納得したか、というと、微妙なのですけど、まあ、現状としてはこんなものでしょう。

実行イメージ

 出来上がりイメージはこんな感じになります。

DateEntryをつくりました

スクリプト

 スクリプトは以下のようになりました。日付を入力するだけなのに、ずいぶんとかかりました。ひょっとしたら車輪の再発明をしたのかも知れませんねぇ。

import tkinter as tk
import tkinter.messagebox as mb
from datetime import datetime

class DateEntry(tk.Frame):
    def __init__(self,master=None,frame_look={},**look):
        args = dict(relief=tk.SUNKEN,bg="white")
        args.update(frame_look)
        tk.Frame.__init__(self, master, **args)

        yearvc = (self.register(self.year_check), "%V", "%d", "%i", "%S", "%P")
        monthvc = (self.register(self.month_check), "%V", "%d", "%i", "%S", "%P")
        dayvc = (self.register(self.day_check), "%V", "%d", "%i", "%S", "%P")
        self.year = tk.Entry(self,width=4,relief=tk.FLAT,validate="all",validatecommand=yearvc)
        self.sep1 = tk.Label(self,text="/",relief=tk.FLAT,bg="white")
        self.month = tk.Entry(self,width=2,relief=tk.FLAT,validate="all",validatecommand=monthvc)
        self.sep2 = tk.Label(self,text="/",relief=tk.FLAT,bg="white")
        self.day = tk.Entry(self,width=2,relief=tk.FLAT,validate="all",validatecommand=dayvc)
        
        self.year.pack(side=tk.LEFT)
        self.sep1.pack(side=tk.LEFT)
        self.month.pack(side=tk.LEFT)
        self.sep2.pack(side=tk.LEFT)
        self.day.pack(side=tk.LEFT)
        
        self.year.bind("<KeyPress>",lambda e:self.key('year', e))
        self.month.bind("<KeyPress>",lambda e:self.key('month', e))
        self.day.bind("<KeyPress>",lambda e:self.key('day', e))

    def setPrev(self, prev=None):
        self.prev = prev

    def setNext(self, next=None,):
        self.next = next
    
    def year_check(self, event, command, index, char, proposed):
        if event == 'key':
            if command == '0': # Delete command
                return True
            else:
                if not char.isdigit():
                    return False
        if event == 'key' and len(proposed) == 4:
            self.month.focus()
        return True
    
    def month_check(self, event, command, index, char, proposed):
        if event == 'key':
            if command == '0': # Delete command
                return True
            else:
                if not char.isdigit():
                    return False
            if int(proposed) < 0 or 12 < int(proposed):
                return False
            if len(proposed) == 2:
                self.day.focus()
        return True
    
    def day_check(self, event, command, index, char, proposed):
        if event == 'key':
            if command == '0': # Delete command
                return True
            else:
                if not char.isdigit():
                    return False
            if int(proposed) < 0 or 31 < int(proposed):
                return False
        if event == 'key' and len(proposed) == 2:
            if self.next:
                self.next.focus()
        return True

    def key(self, w, event):
        if event.keysym == 'Left':
            if w == 'year':
                if self.prev:
                    if self.year.index(tk.INSERT) == 0:
                        self.prev.focus()
            elif w == 'month':
                if self.month.index(tk.INSERT) == 0:
                    self.year.focus()
            elif w == 'day':
                if self.day.index(tk.INSERT) == 0:
                    self.month.focus()
        elif event.keysym == 'Right':
            if w == 'year':
                if len(self.year.get()) == self.year.index(tk.INSERT):
                    self.month.focus()
            elif w == 'month':
                if len(self.month.get()) == self.month.index(tk.INSERT):
                    self.day.focus()
            elif w == 'day':
                if self.next:
                    if len(self.day.get()) == self.day.index(tk.INSERT):
                        self.next.focus()

    def get(self):
        try:
            d = datetime(year=int(self.year.get()),
                        month=int(self.month.get()),
                        day=int(self.day.get())) 
        except:
            return None    
        return d

    def clear(self):
        self.year.delete(0,tk.END)
        self.month.delete(0,tk.END)
        self.day.delete(0,tk.END)

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("DateEntry test")
        self.label = tk.Label(text="日付を入力してください(YYYY/MM/DD):")
        self.label.grid(row=0,column=0)
        self.date_entry = DateEntry()
        self.clear_button = tk.Button(text="Clear", command=self.clear)
        self.close_button = tk.Button(text="Close", command=self.destroy)
        self.show = tk.Button(text="show", command=self.show)
        
        self.date_entry.setPrev(self.show)
        self.date_entry.setNext(self.clear_button)

        self.date_entry.grid(row=0,column=1)
        self.clear_button.grid(row=2,column=0)
        self.close_button.grid(row=2,column=1)
        self.show.grid(row=2,column=2)

    def clear(self):
        self.date_entry.clear()
        
    def show(self):
        d = self.date_entry.get()
        if d:
            message = "入力されたのは、「"+d.strftime("%Y/%m/%d")+"」です。"
        else:
            message = "正しい日付がセットされていません。"
        mb.showinfo(title="info",message=message)

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

解説

 日付を入力するのに、途中のハイフンやスラッシュを入力するのはイケてないよねぇ、と思ったので、なんとかならないかとちょっと調べてみました。ここにありました。みんな同じようなこと考えるのですね。EntryとLabelを組み合わせたFrameをつくることで実現してありましたので、これを参考にしてつくったのが上記のスクリプトです。

 年、月、日をそれぞれEntryでつくります。それぞれvalidatecommandを設定しています。入力は数字のみ、年は4桁入力されたとき、月と日は2桁入力されたとき、それぞれ次の項目に移動するようにしています。月は1~12、日は1~31のみ入力できるようにしました。

 あと、左右のキーで年月日を移動できるようにしてみました。ポイントは現在のカーソル位置が途中の時は項目間の移動ではなくて、項目内の移動にしなくてはならない、というところでしょうか。現在のカーソル位置は、tk.Entry.index(tk.INSERT)で取得できました。

まとめ

 日付項目を実装するだけなのに、けっこうなコーディング量になってしまいました。これって、誰かがもう作っているんじゃないかなぁ、と、思いながら作りましたが、楽しかったです♪

Python tkinter GUI プログラミング Entryその2

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

 在宅勤務二日目、調子が出てまいりました。皆さんはいかがでしょうか。さて、先日はEntryについて軽く紹介しましたが、今日はもうちょっと突っ込んで、日付入力用のEntryをつくることを考えてみたいと思います。

 ちょっとやってみましたが、満足いくものにならなかったので、引き続き検討したいと思いますが、今回作ってみたものを紹介しておきます。「Python GUI Programing with Tkinter」という書籍を参考にして作成してみました。

出来上がりイメージ

日付入力用Entryを作りました

ソースコード

import tkinter as tk
from datetime import datetime

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        vc = (self.register(self.validate), '%V', '%d', '%i', '%S')
        ivc = (self.register(self.invalid), '%V')
        self.dentry = tk.Entry(self,
                               validate="all",
                               validatecommand=vc,
                               invalidcommand=ivc)
        self.error = tk.StringVar()
        self.error_info = tk.Label(self,textvariable=self.error)
        self.button = tk.Button(self,text="Close",command=self.destroy)

        self.dentry.pack(side=tk.TOP)
        self.error_info.pack(side=tk.TOP)
        self.button.pack(side=tk.BOTTOM)
        
    def validate(self, event, command, index, char):
        self.toggle_error()
        if event == "key":
            if command == '0': # 削除のとき
                return True
            else:
                if index in ('01235689'):
                    return char.isdigit()
                elif index in ('47'):
                    return char == '-'
                else:
                    return False
        elif event == "focusout":
            try:
                datetime.strptime(self.dentry.get(),'%Y-%m-%d')
            except:
                return False
        return True
    
    def invalid(self,event):
        if event == 'focusout':
            self.toggle_error("日付形式が正しくありません。")

    def toggle_error(self,error=""):
        self.error.set(error)
        if error:
            self.dentry.config(foreground="red")
        else:
            self.dentry.config(foreground="black")
        
if __name__ == '__main__':
    app = App()
    app.mainloop()

概要

 validateでキー入力されたとき、4番目、7番目の入力では、「-」のみ入力可能としています。それ以外のキー入力は数値のみ入力可能です。フォーカスが外れたときに、日付形式が「’%Y-%m-%d’」となっているかどうかチェックします。ここでエラーが発生したら、「日付形式が正しくありません。」と出力されるようになっています。

 何が気に入らないかというと、入力位置によって、入力できるキーを制限しているので、既に何かが入力されているタイミングでも決まった位置に数字や「-」が入力可能なので、「2020–04-099」のような入力ができてしまいます。

まとめ

 とりあえず、入力チェックが付いたものはできました。しかし、別のやり方ができないかどうか、もうちょっと検討してみます。