Python tkinter GUIプログラミング Canvasで箱をつなぐ線を描くその2

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

 前回の宣言通り、今回は陰線処理です。箱の中心から中心へ線を引くところまでは同じなのですが、箱にかぶる部分は線を引かないようにしましょう。このページを参考にさせていただきました。

できあがりイメージ

 できあがりイメージは前回とほぼ変化なしです。中心から箱までの線が描かれなくなっただけです。

箱と箱を線で結ぶ。ただし、箱の中の線を取り除く。

ソースコード

 ソースコードは以下の通りです。変更点はConnectionクラスだけです。

import tkinter as tk


class Entity():
    ''' Entity class '''
    def __init__(self, canvas, x, y, width=60, height=40):
        self.canvas = canvas
        self.x, self.y, self.width, self.height = x, y, width, height
        self.start_x = self.start_y = None
        self.connections = []

        self.id = self.canvas.create_rectangle(x, y, x + width, y + height,
                                               fill="lightblue", width=3)
        self.canvas.tag_bind(self.id, "<ButtonPress>", self.button_press)
        self.canvas.tag_bind(self.id, "<Motion>", self.move)
        self.canvas.tag_bind(self.id, "<ButtonRelease>", self.button_release)

    def button_press(self, event):
        ''' マウスのボタンが押されたときの処理 '''
        self.start_x = self.canvas.canvasx(event.x)
        self.start_y = self.canvas.canvasy(event.y)

    def move(self, event):
        ''' マウスが移動したときの処理 '''
        if event.state & 256:  # マウスボタン1が押されているときだけ(ドラッグ中のみ)
            can_x = self.canvas.canvasx(event.x)
            can_y = self.canvas.canvasy(event.y)
            coords = self.canvas.coords(self.id)
            coords[0] -= self.start_x - can_x
            coords[1] -= self.start_y - can_y
            coords[2] -= self.start_x - can_x
            coords[3] -= self.start_y - can_y
            self.canvas.coords(self.id, coords)
            self.start_x = can_x
            self.start_y = can_y
            self.x, self.y = coords[0:2]
            for connection in self.connections:
                connection.move(self)

    def button_release(self, event):  # pylint: disable=unused-argument
        ''' マウスのボタンが離されたとき '''
        self.start_x = self.start_y = None

    def get_center(self):
        ''' 中心座標を戻します '''
        return self.x + self.width//2, self.y + self.height//2

    def add_listener(self, connection):
        ''' コネクションのリスナーを登録します '''
        self.connections.append(connection)


class Connection():
    ''' Connection class '''
    def __init__(self, canvas, start_entity, end_entity):
        self.canvas = canvas
        self.start_e = start_entity
        self.end_e = end_entity
        start_entity.add_listener(self)
        end_entity.add_listener(self)

        self.id = self.canvas.create_line(self.get_intersection(start_entity),
                                          self.get_intersection(end_entity))

    def move(self, entity):
        ''' エンティティが移動したときの処理(エンティティから呼び出される)'''
        coords = self.canvas.coords(self.id)
        if entity == self.start_e:
            coords[0:2] = self.get_intersection(entity)
            coords[2:4] = self.get_intersection(self.end_e)
        elif entity == self.end_e:
            coords[0:2] = self.get_intersection(self.start_e)
            coords[2:4] = self.get_intersection(entity)
        self.canvas.coords(self.id, coords)

    def get_intersection(self, entity):
        ''' 矩形との接点を求める '''
        x, y = entity.get_center()
        height, width = entity.height // 2, entity.width // 2
        dx, dy = self.end_e.x - self.start_e.x, self.end_e.y - self.start_e.y
        if entity == self.end_e:
            dx, dy = -dx, -dy
        if abs(dy / dx) < (height / width):  # 垂直側
            x_pos = x + width if dx > 0 else x - width
            y_pos = y + dy * width / abs(dx)
        else:  # 水平側
            x_pos = x + dx * height / abs(dy)
            y_pos = y + height if dy > 0 else y - height
        return x_pos, y_pos


class Application(tk.Tk):
    ''' Application class '''
    def __init__(self):
        super().__init__()
        self.title("Connecter test 2")
        self.geometry("640x320")

        self.canvas = tk.Canvas(self, background="white")
        self.canvas.pack(fill=tk.BOTH, expand=True)

        entity1 = Entity(self.canvas, 40, 80)
        entity2 = Entity(self.canvas, 240, 160)
        Connection(self.canvas, entity1, entity2)
        entity3 = Entity(self.canvas, 420, 60)
        Connection(self.canvas, entity2, entity3)


def main():
    ''' main function '''
    application = Application()
    application.mainloop()


if __name__ == "__main__":
    main()

解説

 今回は、上述したように、Connectionクラスを変更しただけです。主な変更点は、get_intersection()メソッドの追加と、これまで箱側のget_center()メソッドを使って開始点、終点を算出していたところ、この追加したget_intersection()メソッドに変更したところです。このメソッドをどこに持つのがいいのかなぁ、というのはちょっと悩みましたが、箱と箱の関係によって線との交点が変わってくるので、Connectionクラスの方へ実装することにしました。

 get_intersection()メソッドでの計算のポイントは、箱と箱の中心間を結ぶ直線の傾き(dx / dy)と、箱そのものの縦横比(height / width)を比較することで、縦の線と交わるのか、横の線と交わるのか、というのを決めているところですね。縦の線と交わるときはx座標はそのまま戻す、横の線の場合はy座標をそのまま戻す感じになります。実際には上下、左右と二種類あるので、dx、dyの正負を条件にして切り替えるようにしています。座標の位置がそのままじゃない方については、縦線または横線の長さに直線の傾きの比率を掛けることで、求めるようにしています。

 Connectionクラスのmove()メソッドの変更点としては、これまで移動した方の箱の中心点だけ変更していればよかったところを、開始点と終了点の両方を計算する必要がでてきたので、68~73行目で対応しました。

 あと、実は箱が重なった時に変なところに線が引かれてしまうのと、dxが0になったときに「ZeroDivisionError: float division by zero」のエラーが発生しますので、余裕のある方は修正してみてください。

まとめ

 出来上がってみるとなんと言うことはないのですけど、作るのは結構難しいですね。参考になればうれしいです。

Python tkinter GUIプログラミング Canvasで箱をつなぐ線を描く

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

 Canvasで箱を描いて、動かすことをやってきましたけど、箱と箱をつなぐためのコネクションのような線を描くことをやってみました。まあまあうまいことできたように思いますので、紹介させていただきます。

できあがりイメージ

 できあがりイメージは以下のとおりです。箱を描いて、まずは中心同士で直線を結ぶことにしました。箱を動かすと線がついてくる、というのが目標です。隠線処理は、次回のテーマとしたいと思います。

ソースコード

 ソースコードは以下のとおりです。先日お話したとおり、pylintでチェックしているのですけど、一部、リファクタリングのメッセージが残っています。それは次々回以降のテーマでお話したいと思います。(R0902: too-many-instance-attributesとR0913: too-many-arguments)

import tkinter as tk


class Entity():
    ''' Entity class '''
    def __init__(self, canvas, x, y, width=60, height=40):
        self.canvas = canvas
        self.x, self.y, self.width, self.height = x, y, width, height
        self.start_x = self.start_y = None
        self.connections = []

        self.id = self.canvas.create_rectangle(x, y, x + width, y + height,
                                               fill="lightblue", width=3)
        self.canvas.tag_bind(self.id, "<ButtonPress>", self.button_press)
        self.canvas.tag_bind(self.id, "<Motion>", self.move)
        self.canvas.tag_bind(self.id, "<ButtonRelease>", self.button_release)

    def button_press(self, event):
        ''' マウスのボタンが押されたときの処理 '''
        self.start_x = self.canvas.canvasx(event.x)
        self.start_y = self.canvas.canvasy(event.y)

    def move(self, event):
        ''' マウスが移動したときの処理 '''
        if self.start_x is None:
            return
        if event.state & 256:  # マウスボタン1が押されているときだけ(ドラッグ中のみ)
            can_x = self.canvas.canvasx(event.x)
            can_y = self.canvas.canvasy(event.y)
            coords = self.canvas.coords(self.id)
            coords[0] -= self.start_x - can_x
            coords[1] -= self.start_y - can_y
            coords[2] -= self.start_x - can_x
            coords[3] -= self.start_y - can_y
            self.canvas.coords(self.id, coords)
            self.start_x = can_x
            self.start_y = can_y
            self.x, self.y = coords[0:2]
            for connection in self.connections:
                connection.move(self)

    def button_release(self, event):  # pylint: disable=unused-argument
        ''' マウスのボタンが離されたとき '''
        self.start_x = self.start_y = None

    def get_center(self):
        ''' 中心座標を戻します '''
        return self.x + self.width//2, self.y + self.height//2

    def add_listener(self, connection):
        ''' コネクションのリスナーを登録します '''
        self.connections.append(connection)


class Connection():
    ''' Connection class '''
    def __init__(self, canvas, start_entity, end_entity):
        self.canvas = canvas
        self.start_e = start_entity
        self.end_e = end_entity
        start_entity.add_listener(self)
        end_entity.add_listener(self)

        self.make_figure()

    def make_figure(self):
        ''' コネクションを描きます '''
        start_point = self.start_e.get_center()
        end_point = self.end_e.get_center()
        self.id = self.canvas.create_line(start_point, end_point)

    def move(self, entity):
        ''' エンティティが移動したときの処理(エンティティから呼び出される)'''
        coords = self.canvas.coords(self.id)
        if entity == self.start_e:
            coords[0:2] = entity.get_center()
        elif entity == self.end_e:
            coords[2:4] = entity.get_center()
        self.canvas.coords(self.id, coords)


class Application(tk.Tk):
    ''' Application class '''
    def __init__(self):
        super().__init__()
        self.title("Connecter test")
        self.geometry("640x320")

        self.canvas = tk.Canvas(self, background="white")
        self.canvas.pack(fill=tk.BOTH, expand=True)

        entity1 = Entity(self.canvas, 40, 80)
        entity2 = Entity(self.canvas, 240, 160)
        Connection(self.canvas, entity1, entity2)
        entity3 = Entity(self.canvas, 420, 60)
        Connection(self.canvas, entity2, entity3)


def main():
    ''' main function '''
    application = Application()
    application.mainloop()


if __name__ == "__main__":
    main()

説明

 箱を動かす部分については、これまでに書いてきているので省略します。今回の一つ目のポイントは、Entityクラスです。10行目で接続されたコネクションを受け取るためのリストを作成しています。50~52行で定義されたadd_listener()メソッドで、リスナーとしてコネクションを登録できるようにしています。Entityクラスのmove()メソッドの39~40行で、登録されたコネクションのmove()メソッドが呼び出されます。

 2つ目のポイントは、Connectionクラスです。55~79行目です。コネクションが登録されたときに、箱の情報を取得するようにしています。指定された箱のadd_listener()メソッドを使って自分自身を登録します。登録することで、箱が移動したときに、こちらのmove()メソッドを呼び出してもらって、箱の動作にあわせてコネクションがついていくようになります。

まとめ

 箱に連動するコネクターが作れるようになりました。次回は接続部分の隠線処理について書きたいと思います。

Pythonプログラミング Spyderのpylint設定ファイル C0103 doesn’t conform to snake_case naming styleを抑止する

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

 今回は、pylintの設定について書いてみました。急いでいる人のために、やり方は以下のとおりです。これでx、y、idはC0103の規約違反のメッセージが抑止されます。

・pylintrcファイルを作成(以下の2行を記載すれば、x、y、idではメッセージ抑制されます)
・プロジェクトフォルダ(pylint実行対象のファイルがあるフォルダ)に配置

[BASIC]
good-names=x,y,id

動機

 プログラムの保守性を高めることを考えると、コーディングルールに従ってコーディングするのは、とても良い習慣だと思います。と、いうことで、ぼくも最近はPythonの標準的なコーディングルールをチェックしてくれるツールのpylintでチェックしてからアップしているのですけど、ちょっと怪しいルールがありまして。

[C0103]14: Variable name "x" doesn't conform to snake_case naming style

 そう、「x」は、スネークケースのネーミングスタイルではありません、と言われています。pylintは3文字以下の変数については、このメッセージを出してくれて、後で読んだときにわからなくなるような変数名を回避できるように教えてくれます。そうそう、変数名に、a、b、cと付けるとか、省略形は後でわからなくなっちゃうので、避けるべきですよね。でも、GUIプログラミングにおいて、x、yは、使ってもオッケーでしょ。x座標のxですよ。これ、3文字以上にするなら、x_coordinateとかにするのでしょうかねぇ。xで完全に意味がわかるし、x_coordinateだと逆に読みにくい気がするので、良い解決方法と思えません。と、いうことで、今回はこのpylintのメッセージを抑止したいと思います。

 ちょっと調べると、pylintrcファイルに「good-names=」を記述すればよい、ということでこの設定をしたいと思います。ぼくが今使っているのはSpyderからの静的コード分析なので、こちらの設定がSpyderの中にあるかどうかを確認してみました。ツール(T)-設定(F)を開きます。

ツールの設定

 pylintは日本語環境だと「静的コード分析」というところに当たるようです。「静的コード分析」をクリックして内容を確認してみましょう。

設定-静的コード分析

 どうやら、good-namesの設定も、pylintrcファイルに関する設定もできるところはなさそうですね。なので、pylintrcファイルをつくって、結果が出力されるフォルダへ配置してみましょう。

pylintrcファイルに以下のように記述しました。

good-names=x,y,id

 pylint実行してみましたが、特に反応なしでした。と、いうことでSpyderにおけるpylintrcファイルの保存先調べてみましたら、プロジェクトフォルダへ配置せよ、ということでした。おっと、ここまで書いておいてSpyderからのpylint実行方法を書いていなかったことに気づきました。「ソース(C)」-「静的コード分析を実行」です。ショートカットキーは「F8」ですね。

Spyderでpylintを実行する

 他のpylint実行のやり方は、「静的コード分析」ペインを表示して、

「静的コード分析」ペインの表示

 そこの右上にある「分析」ボタンをクリックすると、実行結果が表示されます。こんな感じで出力されます。

pylintの実行結果

 さて、pylintrcファイルをプロジェクトフォルダに配置して、再度実行します。おっと、エラーがでました。

エラー内容

 「File contains no section headers.」ということなので、ファイルの中にセクションヘッダーを書かないといけないのでしょうね。「OK」ボタンを押してみますと、Issueレポーターなどというものが起動されました。おお~、Spyderの内部エラーを回収する仕組みがあるのですねぇ。

Issueレポーター

 でも、今回は、おそらくpylintrcファイルの書き方の問題だと思いますので、送信は不要ですね。とりあえず「閉じる」を押して、この画面を閉じましょう。

 Pylintrc exampleで検索してみたところ、すぐに見つかりました。はい、セクションヘッダーとして、[BASIC]を入れればよさそうです。入れて、保存して、実行してみたところ、無事、x、y、idのメッセージが出力されなくなりました。

まとめ

 pylintをつかって、警告をひとつずつ潰していくと、色々と勉強になります。みなさんもなるべく使うようにしましょう。そして、昔やっていたC言語のコンパイルエラーとか警告を潰していく作業を思い出してなんだかなつかしいです。

Python GUIプログラミング tkinter 自動で消えるスクロールバー

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

 今日は、先日書いた記事のスクロールバーが必要のないときに消える方法について書きました。

出来上がりイメージ

項目の位置によってスクロールバーが出たり消えたりする

ソースコード

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

import tkinter as tk


class AutoScrollbar(tk.Scrollbar):  # pylint: disable=too-many-ancestors
    ''' 必要に応じて自動で消えるスクロールバー
        grid と pack geometry manager で利用可能です'''
    def __init__(self, master=None, **kw):
        super().__init__(master, **kw)
        self.geo_mgr = self.key_word = None

    def set(self, first, last):
        self.geo_mgr = self.geo_mgr if self.geo_mgr else self.winfo_manager()
        if float(first) <= 0.0 and float(last) >= 1.0:
            if self.geo_mgr == "grid":
                self.grid_remove()
            elif self.geo_mgr == "pack":
                self.pack_forget()
        else:
            if self.geo_mgr == "grid":
                self.grid()
            elif self.geo_mgr == "pack":
                self.pack(**self.key_word)
        super().set(first, last)

    def pack(self, **kw):  # pylint: disable=arguments-differ
        ''' pack geometry managerのキーワード引数を保持 '''
        self.key_word = kw
        super().pack(**kw)

    def place(self, **kw):  # pylint: disable=no-self-use
        ''' place geometry manager を使ったときのための警告 '''
        raise tk.TclError('placeは使えません')


class App(tk.Tk):
    ''' メインアプリケーションクラス '''
    def __init__(self):
        super().__init__()
        self.title("Canvas move test")
        self.geometry("400x200")

        # gridバージョン
        self.canvas = can = tk.Canvas(self, background="white")
        sbx = AutoScrollbar(self, orient=tk.HORIZONTAL, command=can.xview)
        sby = AutoScrollbar(self, orient=tk.VERTICAL, command=can.yview)
        can.config(xscrollcommand=sbx.set, yscrollcommand=sby.set)
        self.info = tk.Label(self)
        can.grid(row=0, column=0, sticky=tk.NSEW)
        sbx.grid(row=1, column=0, sticky=tk.EW)
        sby.grid(row=0, column=1, sticky=tk.NS)
        self.info.grid(row=2, columnspan=2)
#        # packバージョン
#        self.canvas = can = tk.Canvas(self, background="white")
#        sbx = AutoScrollbar(can, orient=tk.HORIZONTAL, command=can.xview)
#        sby = AutoScrollbar(can, orient=tk.VERTICAL, command=can.yview)
#        can.config(xscrollcommand=sbx.set, yscrollcommand=sby.set)
#        self.info = tk.Label(self)
#        can.grid(row=0, column=0, sticky=tk.NSEW)
#        sbx.pack(side=tk.BOTTOM, fill=tk.X)
#        sby.pack(sid=tk.RIGHT, fill=tk.Y)
#        self.info.grid(row=1, columnspan=2)

        self.start = self.item = None
        can.bind("<Motion>", self.move)
        can.bind("<ButtonPress>", self.button_press)
        can.bind("<ButtonRelease>", self.button_release)
        can.bind("<MouseWheel>", self.mouse_wheel)
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)
        self.bind("<Configure>", self.resize)

        can.create_rectangle(40, 40, 60, 60)

    def move(self, event):
        ''' マウスが動いたときの処理 '''
        x = self.canvas.canvasx(event.x)  # pylint: disable=invalid-name
        y = self.canvas.canvasy(event.y)  # pylint: disable=invalid-name
        text = f"mouse position = ({event.x}, {event.y}) ({x},{y})"
        self.info.configure(text=text)
        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 = self.canvas.canvasx(event.x)  # pylint: disable=invalid-name
        y = self.canvas.canvasy(event.y)  # pylint: disable=invalid-name
        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):  # pylint: disable=unused-argument
        ''' ボタンが離されたときの処理 '''
        self.start = None

    def resize(self, event):  # pylint: disable=unused-argument
        ''' ウィンドウサイズが変わった時の処理 '''
        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)


def main():
    ''' メインプログラム '''
    app = App()
    app.mainloop()


if __name__ == "__main__":
    main()

説明

 今回は、Scrollbarを継承して、AutoScrollbarを作成しました。ま、基本的には、ここで記載のあった方法を踏襲しています。違いはpackでも利用できるようにしたことくらいでしょうか。ポイントはScrollbarのsetが呼び出されたときに、必要がなければ、grid_remove(pack_forget)を使って見えなくする、というところでしょうか。あと、packでも利用可能にするために、winfo_managerでセットされているgeometry managerを取得してインスタンス変数に保持するようにしました。packでの利用方法は、コメントアウトしてあるところです。
 その他の部分は前回とおんなじです、、、と思いましたが、一部、pylintでチェックして規約違反やら警告やらリファクタと言われたところを修正いたしました。スッキリしました!

まとめ

 スクロールバーが自動で消えるようになりました。

Python GUIプログラミング tkinter Canvas state Mod3

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

 先日書いた記事に修正を加えようと思って、ソースコードを探し出して実行してみたら、なぜかうまく動かなくてずいぶんと悩んでしまいました。今回はそのお悩みを解決した話です。

 修正自体はスクロールバーが必要な時には表示されて、不要な時には非表示になる、という修正をしようと思いたちました(その記事は次回の予定に。。。)。スクロールバー使ったソースはどれかしら、と、探し出して、実行してみたところなんと、ホイールを使ったスクロールがなぜか動作しません。先日、少なくともブログを書いた今年の4月頃までは動作していたのに!
 これはもしや知らないうちにPythonの仕様が変更されたのでは、時々バージョンアップしてるからねぇ、と、Pythonの仕様変更を疑ってしまいました。でも、いやいや待て待て、そんな影響が起きるような仕様変更がそんなに頻繁にあるわけはなかろう、と、気を取り直しまして、、、とりあえず、ソースコードをよく読みこんでみました。
 ええと、ちゃんとホイールが利用されたときのコーディングもされていますね。と、よく見ると、if文の条件が「event.state == 5」となっていますね。このstateは何かのキーが同時に押されると特定のビットを立てていくようになっていたはずです。ちょっと怪しいのでprintして確認してみました。

mouse_wheel: <MouseWheel event state=Mod3 delta=-120 x=231 y=103> 32

 なんと、stateに「Mod3(32 = 2**5)」がセットされている。。。何だこりゃ?!そりゃ、動きませんなぁ。しかし、何でしょうこのMod3は。Google先生に聞いてもなかなか見つけられなかったのですが、、、ありました。ここです。なるほど、ScrollLockですね。値としては、2**5でした。他にもCapsLockでは、2**1が確認できました。ScrollLockとCapsLockが同時にセットされた場合は、2**1 | 2**5となるわけですね。(2進数だと、00100010というように下位から1番目と5番目のビットがセットされてくる。(0オリジンです。))こんな風に、いろいろとセットされてくる可能性があるので、「event.state == 5」でShiftとCtrlが押されていることを判断するのではちょっと足りなかった、ということだったのですね。
 と、いうことでstateをチェックしているところはすべて以下のように修正しました。

if event.state & (2**0 | 2**2) == 5: # Shift|Control
    ...
elif event.state & 2**2 == 4: # Control
    ...
elif event.state & 2**0 == 1: # Shift
    ...
elif event.state & (2**0 | 2**2) == 0: # None
    ...

 ちょっと美しくないので、なんとかしたいところですが、ま、そのうち、おいおいと。

 調べた結果をまとめますと、以下の通りです。マウスクリックしたときの<ButtonPress>イベントについて記載します。

state数値WindowsLinuxMac OSX
Shift2**0 = 1Shiftキー
Lock2**1 = 2Caps Lock
Control2**2 = 4Ctrlキー
Mod12**3 = 8NumLockAltCommand(=Meta)
Mod22**4 = 16NumLockOption(=Alt)
Mod32**5 = 32ScrollLockNumLock
Mod42**6 = 64WinFn
Mod52**7 = 128
0x200002**17 = 131,072Alt
マウスクリックとキー入力状態によるstateの違い(Windows10は実験結果、Linux、MaOSXは未検証、※は不明)

 わからないところが結構あって、埋められていません。どなたかわかる方、教えていただけるとうれしいです。以下のスクリプトを実行すると、イベントがprintされますので、確認してみてください。

import tkinter
c = tkinter.Canvas()
c.pack()
c.bind("<ButtonPress>", print)
c.mainloop()

まとめ

 CtrlキーやShiftキーが押された状態を扱う処理を作る場合は、bit演算が必要です。

Python tkinter GUIプログラミング Canvas の項目リサイズ

 こんにちは。すっかり秋になりましたね。石川さんです。久しぶりの投稿になりました。

 今回は、tkinterのCanvasに四角形を描いて、それの大きさをマウスで変更する、というのにチャレンジしました。

出来上がりイメージ

出来上がりイメージ

 今回使用するtkinterのオブジェクトはCanvasだけです。四角形はcreate_rectangleで描いて、マウスが上に来たら、それっぽいポインターに変わります。その上でドラッグすると、サイズが変更される、というシンプルなプログラムです。

ソースコード

import tkinter as tk


class App(tk.Tk):
    MARGIN = 5

    def __init__(self):
        super().__init__()
        self.geometry("800x700")
        self.title("Canvas item resize example")

        self.canvas = c = tk.Canvas(self, background="white")
        c.pack(fill=tk.BOTH, expand=True)

        x1, y1, x2, y2 = 100, 100, 400, 500
        self.rect = c.create_rectangle(x1, y1, x2, y2, width=App.MARGIN)
        c.bind("<ButtonPress>", self.press)
        c.bind("<ButtonRelease>", self.release)
        c.bind("<Motion>", self.move)
        self.coords = None
        self.position = None

    def get_cursor_position(self, x, y):
        left, right = x - App.MARGIN * 2, x + App.MARGIN * 2
        top, bottom = y - App.MARGIN * 2, y + App.MARGIN * 2
        x1, y1, x2, y2 = self.canvas.coords(self.rect)
        cursors = [
            ["ul_angle",          "sb_v_double_arrow", "ur_angle"],
            ["sb_h_double_arrow", "",                  "sb_h_double_arrow"],
            ["ll_angle",          "sb_v_double_arrow", "lr_angle"]
        ]
        if bottom < y1 or y2 < top:
            return "", None
        elif top <= y1 <= bottom:
            row = 0
        elif top <= y2 <= bottom:
            row = 2
        else:
            row = 1
        if right < x1 or x2 < left:
            return "", None
        elif left <= x1 <= right:
            column = 0
        elif left <= x2 <= right:
            column = 2
        else:
            column = 1
        return cursors[row][column], (row, column)

    def press(self, event):
        self.coords = self.canvas.coords(self.rect)

    def move(self, event):
        x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
        if self.coords and self.position:
            if self.position[1] == 0:  # Left
                self.coords[0] = x
            if self.position[0] == 0:  # Top
                self.coords[1] = y
            if self.position[1] == 2:  # Right
                self.coords[2] = x
            if self.position[0] == 2:  # Bottom
                self.coords[3] = y
            self.canvas.coords(self.rect, *self.coords)

        cursor, self.position = self.get_cursor_position(x, y)
        self.configure(cursor=cursor)

    def release(self, event):
        if self.coords:
            self.coords = None


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

解説

 9行目でウィンドウのサイズ、10行目でタイトルをセットしています。12、13行目、Canvasをつくって配置しています。15、16行目のcreate_rectangle()で四角形を描いています。17~19行目ではbindを使って、<ButtonPress>マウスボタンが押されたとき、<ButtonRelease>マウスボタンが離されたとき、<Motion>マウスが動いたとき、に、それぞれ処理をあてがっています。21、22行は後の処理で利用するときにエラーとならないよう変数を初期化しています。

 23~48行目のメソッド、get_cursor_positionでは、マウスの現在位置からカーソルの種類と位置を戻しています。ポイントは27行目のcursorsリストを配列のように使っているところです。3×3の行列をイメージして、6種類のカーソルを場所によって特定するようにしています。ちなみに26行目のcoordsメソッドは、self.rectの左上の座標と右上の座標を順番に返します。

 50、51行目のメソッド、pressはマウスボタンが押されたときに呼び出される処理です。ここでインスタンス変数のcoordsへ値をセットしています。この値がセットされていることで、ボタンが押されている、ということが他の処理からでもわかるようになっています。ちなみに69~71行目のreleaseはマウスボタンが離されたときに呼び出される処理で、この値(coords)をNoneにセットすることでマウスボタンは押されていない、ということがわかります。

 53~67行目のmoveメソッドが主な処理部分ですね。前半部分はサイズを変更する処理で、後半部分はカーソルの形状を変更する処理です。54行目のcanvasxとcanvasyは、座標系の変更をしています。eventから取得したx、yは、Canvas上のx、yと異なり、画面系の座標なのでcanvasx、canvasyで変換して使う必要があります。55行目の判定は、self.coordsはマウスボタンが押されているときに限るための条件、self.positionはカーソルが近くにある時に限るための条件になっています。<Motion>イベントに対する処理なので、実行される条件をきちんと絞り込む必要がありますね。
 64行目、再びcoordsが登場するのですが、26行目とは違い、第二引数があります。前回は座標を取得するだけでしたが、この第二引数が指定された場合には、項目の座標を第二引数のとおりに変更する、という点が異なります。この一文を指定することで、マウスポインタの移動にあわせて四角形を変形させています。
 66、67行目はカーソルが角っこに移動したときにカーソルの形状を変更するためにここへ入れました。また、ここでself.positionをセットすることで、四角形から離れた場所からのドラッグでリサイズできるようになっています。

まとめ

 いろいろと試行錯誤していましたので条件判定が割合スッキリとできたのではないでしょうか。今回は簡単にするために、四角形はクラス化せずにひとつだけ用意しておくようにしました。実際の現場だったりするとひとつだけ用意すればよい、ということはほとんどないと思いますので、クラス化もやってみたいですね。

在宅勤務ー5 Windows10 Pythonでディスプレイの解像度を変更する

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

 ここのところ、暑さが少し和らいで、少しだけですが秋の気配を感じ始めましたね。
さて、今日は仕事に便利な裏技を紹介したいと思います。「ディスプレイの解像度」と「テキスト、アプリ、その他の項目のサイズを変更する」の値をワンアクションで変更できるようにする方法です。

ディスプレイの設定

 ちなみに、この設定は、画面上の何もないところを右クリックして表示されるコンテキストメニューの、「ディスプレイの設定」を選択することで表示できます。

ディスプレイ設定

やり方

 ぼくは、ここのところPythonにハマっていますので、Pythonを使ってできないかなぁ、ということで調べてみました。以下のPythonスクリプトで変更可能です。ぼくはデスクトップにこのスクリプト置いて、ダブルクリックして実行しています。あ、拡張子は「.py」ファイルにして、この拡張子のファイルをPython.exeで実行するように定義しました。

''' ディスプレイの解像度と倍率を変更します '''
import win32con
import win32api
import subprocess
import time
import pyautogui

proc = subprocess.Popen('DpiScaling') # ディスプレイの設定を開いておきます。
time.sleep(3)
proc.wait()

DEVICE = win32api.EnumDisplayDevices(None, 0)
DEVMODE = win32api.EnumDisplaySettings(DEVICE.DeviceName, win32con.ENUM_CURRENT_SETTINGS)

if DEVMODE.PelsWidth == 2160:
    DEVMODE.PelsWidth = 1920
    DEVMODE.PelsHeight = 1080
else:
    DEVMODE.PelsWidth = 2160
    DEVMODE.PelsHeight = 1440

DEVMODE.Fields = win32con.DM_PELSWIDTH | win32con.DM_PELSHEIGHT
win32api.ChangeDisplaySettings(DEVMODE, 0)


window = pyautogui.getWindowsWithTitle('設定')[0]
if window:
    window.activate()
    if DEVMODE.PelsWidth == 1920:
        pyautogui.typewrite(['tab','tab','up','up'])
    else:
        pyautogui.typewrite(['tab','tab','down','down'])
    pyautogui.hotkey('alt', 'f4')

ソースコードの説明

 今回の設定変更ですが、ノートパソコンからリモートデスクトップ接続で他のパソコンにログインしたときに、解像度が一致していないと挙動がおかしくなることがあって、その対応としてはリモート接続先のパソコンの解像度にあわせてからログインするとうまくいく、ということだったのだけど、何度も設定を切り替えるのが面倒になってきたので、スクリプトを用意して、ダブルクリック一回だけで切り替わらないかなぁ、というのが事の発端です。ちなみにぼくはWindows10のノートパソコン(Let’s Note)を使っています。

テキスト、アプリ、その他の項目サイズを変更するディスプレイの解像度
通常150%(推奨)2160 × 1440(推奨)
リモート時100%1920 × 1080
ディスプレイ設定の切り替え

 このほかにもプロジェクターで発表するときや、Zoomなどのウェブ会議なんかで利用できるかも、と、思っています。

 まずは、「ディスプレイの解像度」の方ですが、こちらはGoogle先生に聞いて、すぐに解決できました。win32apiChangeDisplaySettingsを利用します。

 12行目の「DEVICE = win32api.EnumDisplayDevices(None, 0)」で現在のディスプレイ設定の名称などを取得しています。この0のところを1、2と増やしていって他のデバイスも取得できるらしいので、ちょっと試してみたところ、ぼくの環境では「\\.\DISPLAY1」「\\.\DISPLAY2」「\\.\DISPLAY3」と三つのDEVICE.DeviceNameが取得できました。モニターを複数つないだ時に増えるのかと思っていたのですが、違いました。DeviceIDは同じだったので、同じモニターの異なる設定を持っているのかも知れませんが、とりあえず深追いはやめました。どなたか知っているひとがいたら、教えてください♪

 13行目の「DEVMODE = win32api.EnumDisplaySettings(DEVICE.DeviceName, win32con.ENUM_CURRENT_SETTINGS)」で現在のディスプレイ設定の詳細な設定値を取得しています。ちなみに「\\.\DISPLAY2」「\\.\DISPLAY3」を指定しても以下のようなエラーが発生するだけで設定値は取得できませんでした。

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
pywintypes.error: (123, 'EnumDisplaySettings', 'ファイル名、ディレクトリ名、またはボリューム ラベルの構文が間違っています。')
>

 次に15行目のPelsWidthのチェックですが、現在値が2160のとき切り替えして、そうじゃないときに元の値に戻すようにするために記載してあります。ここは皆さんの事情と環境に応じて書き換える必要がある個所です。22行目の「DEVMODE.Fields = win32con.DM_PELSWIDTH | win32con.DM_PELSHEIGHT」を指定することでその次の23行目の「win32api.ChangeDisplaySettings(DEVMODE, 0)」呼び出しで、指定してPelsWidthPelsHightの値を反映してくれるようになります。

 26行目からは、「テキスト、アプリ、その他の項目のサイズを変更する」の変更です。最初のうちは、上記のChangeDisplaySettingsでこちらも設定できるのでしょうね、と思い込んでしまって、ずいぶんと遠回りしてしまいました。Scaleという項目があるので、これでできそうだと思ったのですよねぇ。いろいろ調べて回った結果、最終的に、pyautoguiで実現することにしました。pyautoguiはGUIで操作することをプログラムから再現するためのモジュールですので、実行中はマウスを触ったりキー入力したりしないようにお願いしますね。途中でクリックしたりすると、正しく動作しなくなりますので。まずは「設定」というタイトルのウィンドウを取得していますが、これは、8行目の「subprocess.Popen('DpiScaling')」で事前に実行しておいた、ディプレイ設定の画面を取得しています。

 設定ウィンドウが取得できた場合に、そのウィンドウをアクティブにして、タブキーを二回押して項目移動して、↑または↓の矢印キー2回で設定を変更しています。終わったら「ALT+F4」で設定画面を終了するようにキー入力しています。もしかしたらこのキー入力は環境によって異なるかも知れませんので、それぞれ変更して利用するようにお願いします。ちなみに、このpyautoguiを使って「ディスプレイの解像度」まで変更できないのか、というツッコミもありそうですけど、できますね。ただ、現在値から設定する値を判断するためにwin32api.EnumDisplaySettingsを使う必要があったので、どうせ使うなら設定変更まで、ということでこのようになっています。

 ちなみに、最初はこちらの「テキスト、アプリ、その他の項目のサイズを変更する」の設定を、ウィンドウズのレジストリを変更して対応する、winregというモジュールを使うやり方を調べて実現できたので、そちらで説明を書こうとしていました。しかし、説明を書きながら確認していくと、思った動きと反対の動きになっていたり、レジストリの値が思ったように変更されなかったり、ということがあったので、pyautoguiを使うように変更しました。ホントは完全にプログラムから実行したかったのですけどねぇ。さすがにレジストリの変更を無責任におすすめできませんよね。でもせっかく調べたので、参考までに、winregを使ったやり方を掲載しておきます。後半部分が異なります。SCALEは、0が推奨値で、正の値は拡大した値、負の値は縮小した値です。今回、-2が推奨値から二つ縮小した値の100%を表しているのですが、実際には4294967294を設定しています。これは、16進数の0xFFFFFFFEを符号なし整数値で表現した値になります。

''' ディスプレイの解像度と倍率を変更します '''
import winreg
import win32con
import win32api

DEVICE = win32api.EnumDisplayDevices(None, 0)
DEVMODE = win32api.EnumDisplaySettings(DEVICE.DeviceName, win32con.ENUM_CURRENT_SETTINGS)

if DEVMODE.PelsWidth == 2160:
    DEVMODE.PelsWidth = 1920
    DEVMODE.PelsHeight = 1080
else:
    DEVMODE.PelsWidth = 2160
    DEVMODE.PelsHeight = 1440

DEVMODE.Fields = win32con.DM_PELSWIDTH | win32con.DM_PELSHEIGHT
win32api.ChangeDisplaySettings(DEVMODE, 0)


REG_PATH = r"Control Panel\Desktop\PerMonitorSettings"
REG_PATH += r"\※ここはそれぞれの値をレジストリで調べて変更してください※"
KEY = winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_WRITE)
if DEVMODE.PelsWidth == 1920:
    SCALE = 0
else:
    SCALE = 4294967294  # -2
winreg.SetValueEx(KEY, "DpiValue", 0, winreg.REG_DWORD, SCALE)
winreg.CloseKey(KEY)

まとめ

 最終的にはpyautoguiがあれば、何でも自動化できそうですね。

追記 2021年1月7日

 上記のスクリプト、ときどきうまくいかないことがあって、しばらく理由がわからなかったのですけど、やっとわかりました!

解像度変更時の通知

 そう!解像度を変更したときにこの通知が画面右下に登場して、どうやらフォーカスを奪っていたのが原因だったようです。スクリプトでは、解像度を変更(→通知が表示)→倍率を変更、というふうに実行していたので、通知が表示するタイミングによって、倍率が変更できないことがあった、ということでした。それに気づいたので、先に倍率を変更してから解像度を変更することにしました。スクリプトで言うと、解像度を変更する部分(最初のスクリプトの23行目)「win32api.ChangeDisplaySettings(DEVMODE, 0)」をスクリプトの一番最後に移動するだけです。

 これでスッキリしました!!!

Python Tkinter GUIプログラミング Text tag

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

 気が付けば、1ヶ月以上も更新していませんでした。採用活動とか、夏休みとか、もろもろあって、忙しかった、かも、と、言い訳してみましたが、よくよく考えてみると、Amazonプライムで海外ドラマの「Elementary」をずっと見ていたのが原因です。すみません。言い訳はよくないですね。

 さて、気を取り直して。ここのところ取り組んでいたtkinterの中にちょくちょく登場してくるタグ(tag)の考え方でちょっと気になったことを調べるためにプログラムを書いてみました。今回はTextで使われるタグについて、です。タグは通常、Textの文字列に色を付けたりフォントを変えたりするのにつかいます。気になりポイントは、同じ文字列にタグをセットしたとき、どちらが有効になるのか、ということです。

 急いでいる方のために結論を言いますと、タグは設定した順に積まれていき、最後の設定が有効になります。前に設定したものを優先するためには、積んだ順を入れ替えれば大丈夫です。

できあがりイメージ

タグの優先順位を変更するプログラム

 プログラムを実行すると、上部のテキストボックスに、赤、緑、青の文字列が初期値として表示された画面が表示されます。「Set Color」ボタンを押すことで、真ん中のラジオボタンで選択した色を、選択した文字列の文字色(foreground)か背景色(background)に、タグと色を付けます。

 下部の一覧は、タグ一覧です。行を選択して、「↑」、「↓」でタグを上下に移動、「DEL」ボタンでタグを削除できます。このボタンでタグを上下することで優先順位が変わった時の挙動が確認できます。行の内訳は、左側がタグ名で、括弧の中が[開始 – 終了]を表します。中の数字は、「行.列」を表していて、行は1オリジン、列は0オリジンの数値がセットされています。

 タグ一覧の中にある「sel」は、特殊なタグで、自動的に作成されます。現在選択されている範囲を表すタグです。

ソースコード

import tkinter as tk
from tkinter import colorchooser


class Application(tk.Tk):
    ''' Textタグの優先順位を確認します。
    タグをセットするためのTextの作成。
    色をセットするためのフレームの作成。
    タグの内容を表示するためのリストボックスを作成。
    リストボックスの名前を移動したり削除したりするためのフレームを作成。
    '''
    def __init__(self):
        super().__init__()
        self.title("Tag Priority")

        self.text = tk.Text(self)
        self.text.pack()
        self.text.bind("<KeyRelease>", self.refresh_list)
        self.text.bind("<ButtonRelease>", self.refresh_list)

        frame = tk.Frame(self)
        self.color = tk.StringVar(value="red")
        radio_red = tk.Radiobutton(frame, text="Red",
                                   variable=self.color, value="red")
        radio_red.pack(side=tk.LEFT)
        radio_green = tk.Radiobutton(frame, text="Green",
                                     variable=self.color, value="green")
        radio_green.pack(side=tk.LEFT)
        radio_blue = tk.Radiobutton(frame, text="Blue",
                                    variable=self.color, value="blue")
        radio_blue.pack(side=tk.LEFT)
        radio_color = tk.Radiobutton(frame, text="Color",
                                     variable=self.color, value="color",
                                     command=self.set_palet_color)
        radio_color.pack(side=tk.LEFT)

        self.color_palette = tk.Canvas(frame, width=20, height=20,
                                       background="white")
        self.color_palette.pack(side=tk.LEFT)
        self.color_palette.bind("<Button-1>", self.set_palet_color, add=True)

        self.option = tk.StringVar(self)
        self.option.set("foreground")
        combo = tk.OptionMenu(frame, self.option,
                              *{"foreground", "background"})
        combo.pack(side=tk.LEFT)

        button = tk.Button(frame, text="Set Color",
                           command=self.set_color)
        button.pack(side=tk.RIGHT)
        frame.pack()

        self.list = tk.Listbox(self)
        self.list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        updown_frame = tk.Frame(self)
        up_button = tk.Button(updown_frame, text="↑", command=self.tag_up)
        up_button.pack()
        del_button = tk.Button(updown_frame, text="DEL", command=self.tag_del)
        del_button.pack()
        down_button = tk.Button(updown_frame, text="↓", command=self.tag_down)
        down_button.pack()
        updown_frame.pack(side=tk.LEFT)

        # テキストの初期値
        self.text.insert(1.0, "*****Red******\n*****Green****\n*****Blue*****")
        self.text.tag_add("red", 1.0, "1.end")
        self.text.tag_config("red", foreground="red")
        self.text.tag_add("green", 2.0, "2.end")
        self.text.tag_config("green", foreground="green")
        self.text.tag_add("blue", 3.0, "3.end")
        self.text.tag_config("blue", foreground="blue")

        self.refresh_list()

    def set_color(self):
        ''' 選択されたTextの文字列に色を付けます。 '''
        tag_range = self.text.tag_ranges('sel')
        if not tag_range:
            return
        option = self.option.get()
        color = self.color.get()
        if color == "color":
            color = self.color_palette.cget("background")
        tagname = "t_"+option+"_"+color+"_"+str(len(self.text.tag_names()))
        self.text.tag_add(tagname, *tag_range)
        self.text.tag_config(tagname, {option: color})
        self.refresh_list()
        for tag_name in reversed(self.text.tag_names()):
            print(tag_name, self.text.tag_ranges(tag_name))

    def refresh_list(self, event=None):
        ''' リストを更新します。 '''
        # pylint: disable=unused-argument
        self.list.delete(0, tk.END)
        for i, tag in enumerate(reversed(self.text.tag_names())):
            tag_range = self.text.tag_ranges(tag)
            if tag_range:
                self.list.insert(i, tag + f"[{tag_range[0]} - {tag_range[1]}]")
            else:
                self.list.insert(i, tag + "[未選択]")

    def tag_up(self):
        ''' タグを一行上に移動します。'''
        self.tag_move(-1)

    def tag_down(self):
        ''' タグを一行下に移動します。'''
        self.tag_move(1)

    def tag_move(self, offset):
        ''' タグを移動します。'''
        active_position = self.list.index(tk.ACTIVE)
        active_value = self.list.get(tk.ACTIVE)
        self.list.delete(active_position)
        self.list.insert(active_position + offset, active_value)
        self.list.activate(active_position + offset)
        self.list.see(active_position + offset)

        active_tag = active_value.split("[")[0]
        above_or_below_tag = self.list.get(active_position).split("[")[0]
        if offset == 1:
            self.text.tag_lower(active_tag, above_or_below_tag)
        else:
            self.text.tag_raise(active_tag, above_or_below_tag)
        print('*'*20)
        for tag_name in reversed(self.text.tag_names()):
            print(tag_name, self.text.tag_ranges(tag_name))
        print('*'*20)

    def set_palet_color(self, event=None):
        ''' パレットの色をセットするためのダイアログを呼び出し、取得した色をセットします。 '''
        # pylint: disable=unused-argument
        initial_color = self.color_palette.cget("background")
        color = colorchooser.askcolor(color=initial_color)
        if color:
            print(color)
            self.color_palette.configure(background=color[1])
        self.color.set("color")

    def tag_del(self):
        ''' リストで選択されたタグを削除します。'''
        active_position = self.list.index(tk.ACTIVE)
        active_value = self.list.get(tk.ACTIVE)
        self.list.delete(active_position)

        active_tag = active_value.split("[")[0]
        self.text.tag_delete(active_tag)


def main():
    ''' メインプログラム(呼び出し用) '''
    main_application = Application()
    main_application.mainloop()


if __name__ == "__main__":
    main()

 今回は、ソースコードにpylintで静的コード分析を実施してみました。意外といろいろとうるさくて、クラスドキュメンテーション文字列がない、メソッドドキュメンテーション文字列がない、変数名、メソッド名のルールがおかしい、使用していない引数がある、カンマの後ろに空白がない、コロンの後ろに空白がない、空行が足りない、等々、いろいろとご指摘いただきました。

 リファクタリングの提案もしてくれていて、クラスのアトリビュートが多すぎるので、見直した方がよいですよ、と、言われていました。

ソースコードの説明

 12行目、__init__(self)でこのアプリケーションの全体を定義しています。16~19行目でTextを作成、定義しています。イベントの<KeyRelease><ButtonRelease>が発生したときに、一覧を更新するように定義しています。何らかのキー入力が終了したときか、マウスボタンクリックが終わった時に、refresh_list()を呼び出します。

 21~51行目、ラジオボタン、カラーパレットのキャンバス、オプションメニュー、「Set color」ボタンを作っています。特に34行目のラジオボタン「Color」が押されたときはカラーパレットを表示するように「command=self.set_palet_color」を定義しています。ここの部分は、もし40行目のbindと同じようにマウスクリックに定義してしまうと、ラジオボタンが選択できなくなってしまいますので、注意が必要です。

 53、54行目、リストボックスを作成、56~63行目で、「↑」「DEL」「↓」のボタンを作成しています。

 65~72行目でTextの初期値をタグ付きの色をセットして、74行目でリストボックスのリフレッシュしています。

 76~90行は、set_color()メソッドで、選択されたテキストの範囲にタグを定義しつつ色をセットしています。現在選択されている範囲を取得するのに、「tag_range = self.text.tag_ranges('sel')」を使用できます。’sel’は「tk.SEL」として定義されていましたので、こちらを利用した方がよかったかもしれません。tag_ranges()で範囲が取得できないとタグをセットするときにエラーになりますので、チェックしています。
 89行目、self.text.tag_names()でタグ名の一覧が取得できますが、作成した順番に格納されていくので、スタックされているイメージに合いません。なので、組み込み関数のreversed()を使って逆順リストを出力するようにしました。

 103~129行目まで、タグが移動できるように定義しています。ポイントは123行目と125行目のself.text.tag_lower(active_tag, above_or_below_tag)self.text.tag_raise(active_tag, above_or_below_tag)です。これらのメソッドは、二つ目の引数がないと一番上か一番下までタグが移動してしまうので、設定しました。上下ボタンでひとつずつ移動してほしかったので。二つ目の引数で指定したタグの下、または上に移動します。

 131~139行目はカラーを選択してもらうためのカラーパレットを表示する機能です。ラジオボタンの「color」を選択するか、色をクリックすると以下のようなウィンドウで色を選択できるようにしています。単にcolor = colorchooser.askcolor(color=initial_color)と、呼び出すだけで使えます。あ、colorchooserは、先頭の方でfrom tkinter import colorchooserとインポートしてあります。

色の設定ダイアログ

 141~148行目まで、タグを削除しています。リストからは、self.list.delete(active_position)を使って削除して、タグの一覧からはself.text.tag_delete(active_tag)でタグを取り除きます。

 151~154行目は、main()関数を定義しました。これまでは、main_application = Application()の行を、if __name__ == "__main__":の下へ記述していましたが、それだとpylintに叱られてしまいまして。なんと、コンスタント値の変数名は、UPPER_CASEの形式で宣言することが望ましいとのことです。Pythonにはコンスタント値という考え方がないので、モジュールレベルの変数はすべてコンスタント値でしょ、というふうに考えられているようです。main()関数の中に記述することでモジュールレベルの変数になることを避ける、というのが一般的な解決方法のようです。

まとめ

 今回はTextで利用されているタグを調べました。タグを使うとテキストの文字色や、背景色、フォントの設定などを変更することができます。複数のタグを使用した場合の優先順位は最後に定義されたタグの方が優先されます。あとからtag_raise()メソッド、tag_lower()メソッドを使って優先順位を変更することも可能です。

Python プログラミング matplotlib subplotの出力順を変更したい

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

 さて、今回は、matplotlibのsubplotとタイトルにありますが、メインはsubplotではありませんね。subplotの出力順を変更したい、というだけの、標準的なプログラミングのお話です。

やりたいこと

 通常、subplotを使うと、複数のグラフを並べて出力することができます。こんな具合です。こちらのサイトを参考に出力してみました。

subplotサンプル

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

import numpy as np
import matplotlib.pyplot as plt

x = np.random.rand(10)
y = np.random.rand(10)
z = np.sqrt(x**2 + y**2)
verts = np.array([[-1, -1], [1, -1], [1, 1], [-1, -1]])
markers = (">", (5, 0), verts, (5, 1), '+', (5, 2))

for i, m in enumerate(markers, 1):
    plt.subplot(3, 2, i)
    plt.scatter(x, y, s=80, c=z, marker=m)

plt.show()

 11行目の、plt.subplot(3, 2, i)でサブプロットを作成しています。最初の二つのパラメータは行列を表しています。今回は、3行2列を表します。iはインデックス値で、1から順番に以下のとおり、左から右、上から下へ、3行2列を以下のような順番で出力していきます。

subplotデフォルトの出力順

 これを以下のように出力したい、という要望です。

subplot出力順をこのように変更したい

 subplotのオプションか何かで簡単に変更できないかと思って調べてみましたが、そのようなオプションは見つけられませんでした。かわりにsubplotsを使って行列の配列を取得する方法があったので、これならば、今回のような工夫は必要なさそうです。ま、でも、今回は頭の体操ですね。もっと簡単な方法があれば、どなたか教えてください。

 1~6のループを割り算したり、余りを求めたり、numpy.transpose()が使えるかも、と、検討してみたけど、これは行列が同じ数のときしか使えないとか、いろいろと実験してみました。その結果、今回は、インデックスを[1, 3, 5, 2, 4, 6]というふうに出力すれば望みどおりに出力できることがわかりました。ここまでくればもうできたも同然ですね。コーディングしてみました。

import numpy as np
import matplotlib.pyplot as plt

x = np.random.rand(10)
y = np.random.rand(10)
z = np.sqrt(x**2 + y**2)
verts = np.array([[-1, -1], [1, -1], [1, 1], [-1, -1]])
markers = (">", (5, 0), verts, (5, 1), '+', (5, 2))


def index_generator(row, col):
    for c in range(col):
        for i in range(c + 1, row * col + 1, col):
            yield i


index = index_generator(3, 2)

for i, m in zip(index, markers):
    plt.subplot(3, 2, i)
    plt.scatter(x, y, s=80, c=z, marker=m)

plt.show()

 ポイントは11~14行目、知ったかぶりをして、ジェネレータを作ってみました。パラメータは行、列です。関数内では、列数分繰り返しますが、1から始めて列数ごとに数字を取り出していきます。これにより、3行2列を指定した場合、列の1回目のループで、1、3、5、を戻し、列の2回目のループで、2、4、6を取り出しています。

 14行目の「yield i」を使うことで関数がジェネレータになります。ジェネレータは、次の要素が取り出されるまで、値を生成しないのでメモリが節約できる、という特徴があります。

 17行目でindexを定義してインデックスの生成にはこちらを利用します。ということで、19行目のforループもenumerateをやめて、zipに変更しました。

まとめ

 割り算や余りを使って計算でできるのでは、と、簡単そうに思いましたが、実は、ちょっとひねりが必要だったという、よい問題でした。

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

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

 しつこくCanvasの移動についてやってみました。前回移動するためのクラスを作ったときに、移動のメソッドをクラス側に作ったけど、呼び出しはメインクラスの方から呼び出していて、それがちょっと気に入らなかったのですが、解決できたので、紹介します。

できあがりイメージ

 できあがりイメージは前回と同じです。機能的にはほとんど同じですが、イベントの処理がクラスに分離できたところが異なります。ま、見た目はまったく同じなのですけど。

ソースコード

import tkinter as tk


class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Canvas move with class")
        self.geometry("1200x800")

        self.canvas = tk.Canvas(self, background="white")

        self.canvas.grid(row=0, column=0, sticky=tk.NSEW)
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)

        self.canvas.bind("<Button-1>", self.button1)

        self.canvas.movers = [Movable(self.canvas, 160, 160, "AliceBlue"),
                              Movable(self.canvas, 200, 200, "LightYellow")]

    def button1(self, event):
        item = self.canvas.find_overlapping(event.x, event.y, event.x, event.y)
        if item:
            return
        m = Movable(self.canvas, event.x, event.y, "Red")
        self.canvas.movers.append(m)

class Movable():
    BOXID = 0
    def __init__(self, canvas, x, y, color):
        self.canvas = c = canvas
        global BOXID
        BOXID += 1
        self.tag = t = "MOVABLE-" + str(BOXID)
        self.t_id = c.create_text(x, y, text=color, tag=t)
        self.r_id = c.create_rectangle(c.bbox(self.t_id), fill=color, tag=t)
        c.tag_lower(self.r_id, self.t_id)
        self.start = None
        c.tag_bind(t, "<Button-1>", self.button1)
        c.tag_bind(t, "<Motion>", self.move)
        c.tag_bind(t, "<ButtonRelease>", self.button_release)
        c.tag_bind(t, "<Button-3>", self.remove)

     def move(self, event):
        if self.start is None:
            return
        x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
        dx, dy = x - self.start[0], y - self.start[1]
        self.canvas.move(self.tag, dx, dy)
        self.start = x, y

    def button1(self, event):
        x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
        self.start = x, y

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

    def remove(self, event):
        c = self.canvas
        c.tag_unbind(self.tag, "")
        c.delete(self.tag)
        c.movers.remove(self)


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

説明

 最初の違いは、イベントをバインドしている16行目です。クリックしたときのイベントですが、これは、Canvasをクリックしたイベントを処理するためのものです。クリックしたところに箱がなければ、新しく箱を作ります。箱を作るための処理は、21~26行目です。以前は、この位置に、move()とbutton_release()がありましたが、今回はありません。これらは全部、クラスの方で処理するようになりました。

 29行目に追加した「BOXID」ですが、Canvas上の項目をまとめるためのタグに連番をつけるためだけのものです。Movableクラスのインスタンスが作られるごとにひとつずつ増えていきます。32行目、globalキーワードを指定することで、BOXIDが外部で宣言されたグローバル変数だということを宣言しています。33行目で1を足して連番が増えるようにして、34行目でtagを「MOVABLE-連番」となるように作成しています。35行目、36行目のテキストと四角を作成しているところでtagを指定しています。

 39~42行目でtag_bind()を使って、このクラスのオブジェクトで発生したイベントとメソッドをバインドしています。ここでのポイントはtag_bind()を使ってタグごとにイベントを割り当てているところです。前回は、キャンバスごとに割り当てていたので、オブジェクトに割り当てるたびに前回のイベント割り当てが上書きされてしまい、うまく動作しなかったのでした。調べた結果、キャンバスごとでも「add=”+”」オプションをつけてバインドすることでイベント割り当てが上書きされないということが分かったのですが、tag_bind()だとタグごとの割り当てになるので、こちらの方がしっくりきますね。

 バインドされたメソッドの方ですが、イベントはそれぞれのタグごとに発生するので、クラス内でこのオブジェクトが選択されたかどうか、という判定は不要になりました。

 あと、今回は右クリックで箱を消去するようにしてみました。

まとめ

 いかがでしょう、前回よりスッキリしたと思いませんか?
いや~、また自画自賛かなぁ。(笑)