Python tkinter GUI Programing バーコード

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

 先日、おしごとでバーコードを出力することがあって、ちょっと苦労しました。code128のバーコードを出力しなきゃいけなかったのですけど、このコード、スタートコードがあって、データ部分があって、チェックディジットのコードがあって、ストップコードがある、という風な構造になっていました。これだけ調べて出し方がわかるまでにも結構時間がかかりましたよ~。バーコード、難しいですねぇ。

 もっと簡単にできる方法を調査すべく、Python tkinterではどうやってバーコードを出すのかな、ということで調べました。まったくやり方がわからないので、Python barcodeでグーグル先生に聞いてみました。便利な世の中ですねぇ、すぐにPython-barcodeを使えばよいことがわかりました。

 出力結果を出すまで紆余曲折ありましたが、なんとか出力することができるようになりました。

できあがりイメージ

Barcodeを出力してみました

ソースコード

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

import barcode
from barcode.writer import ImageWriter
from PIL import ImageTk
import tkinter as tk
import tkinter.filedialog as fd
import tkinter.messagebox as mb

class Application(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Tkinter barcode test")
        
        self.label = tk.Label(self, text="Input code128:")
        self.label.grid(row=0, column=0)
        self.barcode = tk.StringVar()
        self.barcode.set('Python tkiner')
        self.entry = tk.Entry(self, textvariable=self.barcode,width=50)
        self.entry.grid(row=0, column=1, columnspan=2)
        self.show_button = tk.Button(self, text="Show a barcode")
        self.show_button.bind("<Button-1>", self.create_image)
        self.show_button.grid(row=1,column=1)
        self.save_button = tk.Button(self, text="Save a barcode image")
        self.save_button.bind("<Button-1>", self.save_image)
        self.save_button.grid(row=1,column=0)
        self.canvas = tk.Canvas(self, background="white")
        self.canvas.grid(row=2,columnspan=3,sticky=tk.NSEW)
        self.update()
        self.create_image(None)
        
    def create_image(self, event):
        data = self.barcode.get()
        if not data:
            return
        try:
            code128 = barcode.get('code128', data, writer=ImageWriter())
        except barcode.errors.IllegalCharacterError as e:
            mb.showerror("Error", e)
            return
        image = code128.render()
        photo = ImageTk.PhotoImage(image)

        self.canvas.delete(tk.ALL)
        self.canvas.create_image(self.canvas.winfo_width()//2,self.canvas.winfo_height()//2,image=photo)
        self.image = photo
        
    def save_image(self, event):
        data = self.barcode.get()
        if not data:
            return
        try:
            code128 = barcode.get('code128', data, writer=ImageWriter())
        except barcode.errors.IllegalCharacterError as e:
            mb.showerror("Error", e)
            self.update_idletasks()
            return
        filename = fd.asksaveasfilename()
        if filename:
            code128.save(filename)
        

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

詳細

 Python-barcordは標準モジュールではありませんので、インストールが必要でした。ぼくの環境はAnacondaですので、通常はcondaを使ってインストールするのですが、condaで管理しているところにはセットアップされていなかったので、インストラクションどおり、pipを利用しました。

pip install python-barcode

 インストールは、上記のコマンド一行、これだけです。簡単ですねぇ。一瞬で終了しました。

 インストールが完了しましたので、インストラクションどおりに動作するかどうか確認してみました。よくわからないことにチャレンジするときは、ひとつひとつ動作確認していきます。まずは、説明通り動作するか、書かれてあることを試してみます。そして、自分が実現したいことで利用可能なのかどうか、ちょっとずつ、試していくことになります。地道な作業です。

 今回は、ちょっとずつ試したことは割愛して、ソースコードの説明をしたいと思います。

 まずは、入力されたコードをもとに、バーコードを出力できるようにしたいと思います。バーコード出力先には、キャンバスを使うことにしました。ラベルやボタンでもイメージをセットできるようです。__init__では、これらのtkitnerのウィジェットを組み合わせ枠組みを組み立てています。今回の主ポイントではないので割愛します。

 バーコードのデータは35行目のcode128 = barcode.get('code128', data, writer=ImageWriter())で作成しています。第一引数の’code128’はcode128形式のバーコードを作ることを指定しています。dataは作成されるバーコードのもととなるデータで、これはEntry()で入力されたデータです。15行目で定義したStringVar()のインスタンスを17行目のEntry()インスタンス作成時のパラメータtextvariableへセットすることで、Entry()の内容が変わると自動的にStringVar()インスタンスの値も更新されるようになります。writerを指定しない場合は、svg形式のイメージができるようです。今回はtkinterで利用可能なpngへ変更するためにImageWriter()を指定しました。

 39行目のrender()で作成したイメージデータを出力しています。40行目のphoto = ImageTk.PhotoImage(image)この部分ですが、ImageTK.PhotoImage()は、tkinterで使えるようイメージ変換してくれます。

 で、ポイントは43行目、キャンバスの中央にイメージを作成しています。create_image()メソッドです。指定する座標は作成するイメージの中心点ということだったので、キャンバスの幅と高さを取得してその半分を座標にしました。やっとイメージがキャンバス上に表示されるようになりました。しかし!動かしてみるとわかりますが、これだけではイメージ出力されません。その次の44行目がミソです!イメージをクラスインスタンスに代入しています。これがないと、ローカル変数の値は、ガーベージコレクト、要するにメモリのお掃除対象になってしまって、処理が終わったら消えてしまうのです。最初はこれがなくて、バーコード表示されなかったのですよねぇ。

まとめ

 いや~、今回はがんばりました!バーコード作成は、需要があると思うのですけど、日本語の情報が少ないのですよね。誰かのお役に立つと、うれしいです。

Python tkinter GUI プログラミング Treeview

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

 今日は、Treeviewについて調べてプログラミングしてみました。Treeviewには二種類の見せ方があって、headingstreeです。それぞれ上下に実装してみました。

できあがりイメージ

Treeviewの実装サンプル(showの二つのモード、上がheadings、下がtree)

ソースコード

import tkinter as tk
import tkinter.ttk as ttk

class Application(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("ttk Treeview Widget example headings/tree")
        
        lf1 = tk.LabelFrame(self, text="headings")
        lf1.grid(row=0,column=0, padx=10,pady=10)
        headings = {"#1":"名前", "#2":"型", "#3":"サイズ"}
        self.treeview = ttk.Treeview(lf1, columns=headings.keys(), show="headings")
        self.treeview.pack(side=tk.LEFT, fill=tk.BOTH, expand=True,padx=10,pady=10)
        self.scroll = ttk.Scrollbar(lf1, command=self.treeview.yview)
        self.treeview.configure(yscroll=self.scroll.set)
        self.scroll.pack(side=tk.LEFT, fill=tk.BOTH, pady=10)
        
        for key, item in headings.items():
            self.treeview.heading(key, text=item)
            
        self.treeview.column("#1", width=500)
        
        self.treeview.insert("",tk.END, values=("head_information","dict","3"))
        self.treeview.insert("", tk.END, values=("tooltip_text","str","1"))
        
        lf2 = tk.LabelFrame(self, text="tree")
        lf2.grid(row=1,column=0, stick=tk.W, padx=10, pady=10)
        style = ttk.Style(lf2)
        style.configure('Treeview', rowheight=30)
        self.treeview2 = ttk.Treeview(lf2, show="tree")
        self.treeview2.heading("#0", text="tree view version", anchor=tk.W)
        self.treeview2.pack(padx=10, pady=10)
        parent1 = self.treeview2.insert("", tk.END,text="head_information")
        self.treeview2.insert(parent1, tk.END, text="dict")
        self.treeview2.insert(parent1, tk.END, text="3")
        parent2 = self.treeview2.insert("", tk.END, text="tooltip_text")
        self.treeview2.insert(parent2, tk.END, text="str")
        self.treeview2.insert(parent2, tk.END, text="1")
        
if __name__ == "__main__":
    application = Application()
    application.mainloop()

詳細説明

 9行目と26行目でLabelFrame()を作成しています。それぞれheadings用と、tree用に準備しました。12行目でshow="headings"オプションをつけたTreeviewを作成しています。columnsオプションでカラムを指定しています。

 19行目のheadings()メソッドで、項目のヘッダ部分のテキストを設定しています。21行目でヘッダ項目の幅をセットしています。

 headingsを指定することで、このような表形式でデータを扱うことができます。

 30行目で、show="tree"オプションを付けたTreeviewを作成しています。フォルダ階層などのツリー形式のデータを表現することができます。

まとめ

 headingsオプションを使ったTreeviewは、データベーステーブルからデータを取得して一覧を作成するときなどに、使えそうですね。treeオプションの方は、フォルダ階層以外にもプログラム構成や、組織構造など、幅広く使えそうです。

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

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

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

出来上がりイメージ

ソースコード

import tkinter as tk

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

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

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

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

詳細説明

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

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

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

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

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

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

まとめ

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

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

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

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

実行イメージ

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

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

ソースコード

import tkinter as tk

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

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

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

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

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

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

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

解説

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

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

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

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

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

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

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

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

まとめ

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

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

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

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

出来上がりイメージ

Outlineサンプル

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

ソースコード

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

import tkinter as tk

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

    def get_canvas(self):
        return self.canvas

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

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

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

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

詳細

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

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

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

まとめ

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

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

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

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

できあがりイメージ

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

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

ソースコード

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

import tkinter as tk

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

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

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

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

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

まとめ

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

Pythonプログラミング 憧れのテストファースト2

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

 前回unittestモジュールの概要をお伝えしたのですが、Mockについてお伝えしていませんでした。Mockは、モックとかスタブとかパッチとか、いわゆる、仮想のプログラムを簡単に実現するための仕組みです。

 通常は、誰かが作っているライブラリを呼び出して、その結果をもとに振る舞いが変わるようなプログラムを作っているようなときに、自分のプログラムをテストするために必要になるプログラムです。自分の作成部分のみをテストしたいわけですから、ライブラリがこのような値を返してくるはず、という想定があって、その想定をもとに処理を書いていますので、テストでは、その想定値を返してくるプログラムがあればよいわけですね。

 では、テスト駆動開発の実践をしながら、Mockを使ってみたいと思います。テスト駆動開発は、レッド、グリーン、リファクタリングが1サイクルですね。unittestだと色はつきませんけど、ちょっとやってみます。今回は現在時刻を返してくるプログラムを想定してみます。

サンプルプログラム

 いつもは出来上がったソースコードを載せるのですが、今回は、テストファーストを試みてみます、ということでまずは、想定するテストを書いてみます。

# テストコード
import unittest

class MyWatchTest(unittest.TestCase):
    def setUp(self):
        self.mywatch = MyWatch()
    def tearDown(self):
        del(self.mywatch)
    def test_time(self):
        self.assertEqual(self.mywatch.time() == "12:05:20")

 はい、実行してみます。

 おや?何も起きません。。。お、そうそう、メインプログラムを書き忘れていました。最後に追記します。

if __name__ == "__main__":
    unittest.main()

 はい、再度、実行します。

 エラーは発生したのですけど、、、前回動かしたテストもいっしょに動いています。なぜでしょうか。。。?

 dir()を実行してみると、前回作成したテスト用のクラス「MyFirstTestCase」が残っていました。

In [36]: dir()
Out[36]: 
['In',
 'MyFirstTestCase',
 'MyWatchTest',
 'Out',
 '_',
 '_10',
 '_16',
... 以下略

と、いうことで、「MyFirstTestCase」を削除して、もう一度実行してみます。

In [37]: del(MyFirstTestCase)

In [38]: runfile('C:/work/tkinter_example/mywatchtest.py', wdir='C:/work/tkinter_example')
E
======================================================================
ERROR: test_time (__main__.MyWatchTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:/work/tkinter_example/mywatchtest.py", line 11, in setUp
    self.mywatch = MyWatch()
NameError: name 'MyWatch' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (errors=1)

In [39]: 

 はい、望み通り、エラーが発生しました。クラスが作られていない、ということですね。当然です。クラスをつくって、エラーを取り除きます。それにしても、unittestは自動的にテストを探し出して実行してくれているのですね。

MyWatch.py

# 時刻を返すプログラム
class MyWatch():
    pass

 とりあえず、クラスをつくって最初のエラーを回避。テストを実行。エラー内容が変わりません。。。作ったのにモジュールが存在していない、ということは、テストケースを実行するときに、importが必要ですね。import unittestの下に一行追加。

import MyWatch

 再度、実行。お、エラーメッセージが変わりました。「NameError: name 'MyWatch' is not defined」と主張していたところが、以下のように変化しました。エラーは回避できていませんが、メッセージが変わったので、一歩前進です。

TypeError: 'module' object is not callable

 ええと、どうしてでしょうねぇ。。。そうそう、わかりました。import の書き方が違いました。変更します。

from MyWatch import MyWatch

 はい、実行しましょう。モジュール名とクラス名が同じだと、意味がわかりずらいですね。次回からは気を付けましょう。そういえば、Pythonにはおすすめの命名規則があったような。。。調べたら、モジュール名は、snake_caseスタイルに準拠するのが望ましいそうです。ま、今日のところは、これでいきましょう。

EReloaded modules: MyWatch

======================================================================
ERROR: test_time (__main__.MyWatchTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:/work/tkinter_example/mywatchtest.py", line 16, in test_time
    self.assertEqual(self.mywatch.time() == "12:05:20")
AttributeError: 'MyWatch' object has no attribute 'time'

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (errors=1)

 ちょっと進みました。やっと、timeという属性はありません、というところまでたどり着きました。では、timeメソッドを追加してみましょう。これ、最速でテストを通すためには、以下のようにすればよいですね。

    def time(self):
        return "12:05:20"

 実行してみます。おお、AttributeErrorのところが以下のように変わりました。

TypeError: assertEqual() missing 1 required positional argument: 'second'

 お恥ずかしい、assertEqual()の使い方を間違っていました。

        self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")

 これで、どうでしょう。またテストを実行してみます。

 はい、成功しました。

.Reloaded modules: MyWatch

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

 これで、やっと1サイクルできました。ま、リファクタリング、してませんけどね。次は、ちゃんと時刻を返すようにしましょう。datetimeモジュールをインポートして、return文を以下のように修正しましょう。

        return datetime.strftime(datetime.now(),"%H:%M:%S")

 これで実行すると、以下のようにエラーが発生します。

FReloaded modules: MyWatch

======================================================================
FAIL: test_time (__main__.MyWatchTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:/work/tkinter_example/mywatchtest.py", line 16, in test_time
    self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")
AssertionError: '20:16:16' != '12:05:20'
- 20:16:16
+ 12:05:20
 : time()が間違っています。

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=1)

 はい、実装できましたが、エラーになりました。テストを通すためには、datetime.now()の戻り値が「12:05:20」になる必要があります。こういうときに、Mockが役に立ちます。具体的には、以下のような感じになります。まずは、from unittest.mock import Mockと、Mockをインポートして、test_time()メソッドを以下のように修正します。

    def test_time(self):
        original_time = self.mywatch.time
        self.mywatch.time = Mock(return_value="12:05:20")
        self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")
        self.mywatch.time = original_time

 実行すると、成功です。このようにMockを使うことで、他への影響なくtime()のふるまいを変更することができました。ただ、毎回このように元のライブラリを保持しておくのはかなり悩ましい問題ですよね。より簡潔なアプローチとして、patchが用意されています。コンテキストマネージャの形式だと以下のようになります。

    def test_time(self):
        with patch('MyWatch.datetime') as faketime:
            faketime.now.return_value = datetime.datetime(2020,5,8,12,5,20)
            faketime.strftime.side_effect = datetime.datetime.strftime
            self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")

 また、デコレーター形式で記述する以下のようになります。

    @patch('MyWatch.datetime')
    def test_time(self, faketime):
        faketime.now.return_value = datetime.datetime(2020,5,8,12,5,20)
        faketime.strftime.side_effect = datetime.datetime.strftime
        self.assertEqual(self.mywatch.time(), "12:05:20", "time()が間違っています。")

 どれも大して変わりませんね。

まとめ

 unittest.Mockを使って、テスト時のふるまいに変更を加えてみました。Mockが使えるようになると、出来上がっていないライブラリをこちらの想定の下に自分のプログラムをテストすることができるようになるなど、仕事の幅が広がりそうですね。

Pythonプログラミング 憧れのテストファースト

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

 テストファーストのコンセプトを初めて聞いたとき、めっちゃステキなアイディアやん、実現できたらどれだけしあわせなひとが増えることでしょう、と、思っていたのを思い出しました。何度かチュートリアルを経験して、すっかり忘れ去っていました。

 基本的な考え方は簡単で、

  • テスト用プログラムを追加
  • すべてのテストを実行して、追加したテストのエラーが発生するのを確認する
  • テスト対象のプログラムを修正する
  • すべてのテストを実行して、追加したテストが成功することを確認する
  • リファクタリングして、重複を排除、プログラムをきれいにする

という作業を繰り返してプログラムを育てていくプログラミング方法です。

 メリットがいくつかあって、

  • 実行可能なテストが残るため、変更があった時にも再度そのテストを実行することで、これまでの動作保証ができる。
  • テストを先に考えることで、先に仕様を明確にする必要がでてくる。
  • プログラムがテストをやってくれる。
  • テストプログラムを見ることで、作成されたプログラムの利用方法がわかる。
  • プログラミングを「動作させるためのコーディング作業」と「きれいにするためのコーディング作業」に分離できる。

 テストファーストでやっているプロジェクト、これまで見たことがなかったのですよねぇ。現在は増えてきているのでしょうか。問題点は、既存プロジェクトに途中からその考え方を持ち込むことができない(やりにくい)、と、言ったところでしょうか。経験がないのは、自分でやってみるしかないでしょう、ということで、まずは導入部分をやってみたいと思います。

Pythonでのテストファースト

 Pythonの標準モジュールの中に、テスト用フレームワークのunittestがあります。標準ドキュメントにユニットテストフレームワークの説明がありますので、詳細はここで分かると思います。今回は簡単な利用方法について説明します。

unittestの使い方

 テスト用クラスの作成は、unittest.TestCaseを継承して作成します。作成したテストクラスの中にtest_で始まるメソッドを作成することでテストが追加できます。例えば、こんな感じです。

import unittest

class MyFirstTestCase(unittest.TestCase):
    def test_my_first_test(self):
        self.assertEqual(1, 1)

    def test_my_second_test(self):
        self.assertNotEqual(1, 2)

if __name__ == "__main__":
    unittest.main()

 実行するとこんな結果が出力されます。

..
----------------------------------------------------------------------
Ran 2 tests in 0.005s

OK

 出力結果ですが、最初の「..」の点ひとつがテストメソッド一件を表していて、テストが失敗したときに「E」が出力されます。失敗するテストケースを追加してみます。

    def test_my_third_test(self):
        self.assertFalse(True)

 結果はこんな感じの出力になります。

..F
======================================================================
FAIL: test_my_third_test (__main__.MyFirstTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:/work/tkinter_example/unit_test_example.py", line 18, in test_my_third_test
    self.assertFalse(True)
AssertionError: True is not false

----------------------------------------------------------------------
Ran 3 tests in 0.005s

FAILED (failures=1)

 三つ目に「E」が出力されました。

 各テストを実行する前に必ず実行したい処理がある場合は、setUp()メソッドを追加します。各テストの終了時に必ず処理を実行したい場合は、tearDown()メソッドを追加します。

    def setUp(self):
        print('setUp executed!')

    def tearDown(self):
        print('tearDown executed!')

 実行するとこんな風な出力結果になります。

..FsetUp executed!
tearDown executed!
setUp executed!
tearDown executed!
setUp executed!
tearDown executed!

======================================================================
FAIL: test_my_third_test (__main__.MyFirstTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:/work/tkinter_example/unit_test_example.py", line 24, in test_my_third_test
    self.assertFalse(True)
AssertionError: True is not false

----------------------------------------------------------------------
Ran 3 tests in 0.006s

FAILED (failures=1)

 ちょっと「..F」のすぐ後ろに「setUp」の出力結果が続いて出ているのが気になりますが、ちゃんとテストを実行する前後に実行されているようです。

 テストケースの最初に1回だけ、最後に1回だけ実行するようなメソッドは、それぞれ、setUpClass()とtearDownClass()を追加します。ただし、クラスメソッドとして追加する必要があります。

    @classmethod
    def setUpClass(cls):
        print('setUpClass executed!')

    @classmethod
    def tearDownClass(cls):
        print('tearDownClass executed!')

 実行すると以下のように出力が変わります。

..FsetUpClass executed!
setUp executed!
tearDown executed!
setUp executed!
tearDown executed!
setUp executed!
tearDown executed!
tearDownClass executed!

と、長くなってしまいましたが、おおむねこのような感じでテストケースを作成して、プログラムを作ることになります。

Python matplotlib 愛の方程式?

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

 明日からゴールデンウィークですね。だけどどこかへお出かけすることもなかなか難しい状況ですよね。こういう状況なので、このごろ、SNSを見る時間がちょっぴり増えてきました。先日、たぶんツイッターだと思うのですけど、エプロンに書かれた方程式の話題がおすすめで出てきました。確か、奥さまからもらったエプロンに謎の数式が描かれてあって、どうしても気になったので、描画してみたら、奥さまの方のエプロンにすでにその描画結果が描かれてあったというお話でした。ぼくは、とっさに、「これをちょっと描画してみたい」という欲求にかられまして、お試しにmatplotlibを使ってプロットしてみることにしてみました。

 書かれていた方程式、絶対に覚えておけないと思って、とっさにメモをしました。メモには手書きではなくて、Googleキープを使いました。ルートの記号などの数式が描けなかったので、プログラミング的な数式に置き換えて、テキストで保存しました。なので、式があっているかどうかちょっぴり不安なのですよねぇ。

x**2+(y-(x**2)**(1/3))**2=1

 環境依存の記号で書くと以下のようになります。他の人たちも、ちゃんと読めるかなぁ。。。

x² + (y - ∛x²)² = 1

 最近、データサイエンティストの学習で、y=の形式にすればグラフが描ける、ということを知っていたので、まずは、展開するのかなぁ、と、調査開始。この式、まあまあ話題になっているようです。いろいろと探していると、式を展開せずとも描画できそうな記事に出会いました。

スクリプト

 上記のURLを参考に作ったスクリプトは以下の通りです。シンプルでしょ?

import numpy as np
import matplotlib.pyplot as plt
fig, ax = plt.subplots()

y, x = np.ogrid[-1.5:2:1000j, -1.75:1.75:1000j]
plt.contour(
    x.ravel(), y.ravel(), x**2 + (y - (x**2)**(1/3))**2, [1])
plt.show()

実行結果

ハートです♪

まとめ

 描画、見事に成功しました。

 マニアックな愛の告白ですよねぇ、でも、嫌いじゃないです。(笑)

Python GUIプログラミング Rounded Rectangle

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

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

できあがりイメージ

角が丸い矩形

ソースコード

import tkinter as tk

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

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

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

説明

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

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

まとめ

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