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 プログラミング モンテカルロ法で円周率(π)を求める

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

 最近、データサイエンティストの勉強を再開しました。前回も勉強したのですけど、再び登場してきて引っかかったので、ちょっとメモしておきます。

モンテカルロ法とは

 モンテカルロ法とは、乱数を使って数値計算やシュミレーションの近似解を求める方法です。今回は、これを使ってπを求めました。

πの求め方

 具体的には、1×1の正方形の中にランダムな点を打っていって、半径1の扇形の中に入る点と、入らない点に分けて、その比率を計算します。円の面積は、半径 × 半径 × πですので、この半径1の面積は、1 × 1 × π = πとなり、この扇形の面積はその四分の一になりますのでπ/4です。ここから扇形とこの正方形の比率より、π/4 : 1 = 内側の点の数 : すべての点の数という式が成り立ちます。この比例式からπ = 4 × 内側の点の数 / すべての点の数ということでπが算出できます。プログラムでランダムに10000個の点を打って、πの近似値を算出します。

半径1の扇形

ソースコード

import numpy as np
import math
from pandas import DataFrame

r1 = np.random.uniform(0.0, 1.0, 10000)
r2 = np.random.uniform(0.0, 1.0, 10000)
p = DataFrame([(x, y) for x, y in zip(r1, r2) if math.hypot(x, y) < 1])

print(4 * len(p) / 10000)

 ぼくの環境で5回実行した結果は、3.1292、3.1384、3.15、3.168、3.1356という感じでした。まあまあ近いですね。回数を増やせば精度が上がっていくと思います。

 あ、ソースコードの方、ちょっと説明をすると、np.random.uniform(始点,終点,個数)は、始点から終点までの一様乱数を個数で指定された数発生します。一様乱数とは、範囲の数値が等確率で乱数のことです。今回のこの例では、10000個の乱数を発生させています。そして、math.hypot(x,y)は、ユークリッド距離、つまり√(x*x+y*y)を計算した結果を返します。つまり、この関数で原点からx,y座標で指定した点までの距離が1より小さいかどうかを判定できます。

 この結果を使ってデータをプロットしてみます。上の半径1の扇形を出力したスクリプトに追記してみます。

import numpy as np
import matplotlib.pyplot as plt

x = np.arange(0.0,2.0,0.001)
y = np.sqrt(1-x**2)
plt.figure(figsize=(5,5))
plt.xlim(0.0,1.0)
plt.ylim(0.0,1.0)
plt.plot(x,y)
plt.plot(p[0],p[1],'.')
半径1の扇形の内部に点を描画

まとめ

 モンテカルロ法、乱数を使って近似値を求められるなんて、ちょっとカッコいいですよね!

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()を使います。これで、もとのフォントサイズを少しずつ大きくしてすることに成功しました。

まとめ

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

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に割り当てました。

まとめ

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