at backyard

Color my life with the chaos of trouble.

tkinterのgirdを使ったウィジェットの配置について

あけましておめでとうございます。2022年もよろしくおねがいします。

昨晩は妻からプレゼントしてもらったニューマットレスを敷いて寝たのですが、マットレスが変わるとこうも寝心地が変わるものかと驚いています。 まるでホテルの良質なベッドで寝たかのような寝心地でした。

元旦から良い睡眠させてもらいました。

肩のこりも幾分楽になっているような気がする。

tkinterのgirdを用いたウィジェットの配置について

さて、本題。

tkinterではウィジェットの配置にはpack, grid, placeを用いるが、今回はこのgridについて調べたのでそちらについて書いていく。

tkinterのgridはrow(行番号)とcolumn(列番号)を指定しながらウィジェットを配置する。
イメージ的にはExcelのセルに配置するようなイメージとなる。

例えば下記のようなコードを実行した場合、

import tkinter as tk

class App:

    def __init__(self, root):
        self.root = root
        root.title("grid サンプル")
        root.geometry("600x400")
        tk.Label(root, text="ラベル1").grid(row=0, column=0)
        tk.Label(root, text="ラベル2").grid(row=0, column=1)
        tk.Label(root, text="ラベル3").grid(row=1, column=0)
        tk.Label(root, text="ラベル4").grid(row=2, column=1)


def main():
    root = tk.Tk()
    app = App(root)
    app.root.mainloop()


if __name__ == "__main__":
    main()

下記のようなウィジェットの配置となる。

f:id:shinshin86:20220101071754p:plain

[余談] gridの公式ドキュメントについて

なお、ここでtkinterのgridに関する公式ドキュメントを探してみた。

というのも tkinter grid python などで検索すると公式ドキュメントは検索結果の1ページめには出てこない。
なぜ公式ドキュメントが一枚目に出てこないのか?と思いつつ、公式ページからたどってたどり着いたgridの公式ドキュメントがこちらとなる。

grid manual page - Tk Built-In Commands

なかなか無骨なデザインになっているので、これを読んでいくよりはどこかのブログで分かりやすく解説されている記事でも読んだほうが精神的には楽かもしれない...

ただ、公式ドキュメントであるし、網羅的にはまとまっているので困ったらこちらを見るのはありかもしれない。

gridでExcelのセル結合的なことを実現させる

さきほどgridはExcelのセル的な考えに近いと書いたが、例えばセル結合的なことを行ったウィジェット配置も可能で、その場合には columnspan という属性を渡す。

import tkinter as tk

class App:

    def __init__(self, root):
        self.root = root
        root.title("grid サンプル")
        root.geometry("600x400")
        tk.Label(root, text="ラベル1").grid(row=0, column=0)
        tk.Label(root, text="ラベル2").grid(row=0, column=1)
        tk.Label(root, text="ラベル3").grid(row=1, column=0, columnspan=2) # columnspanを追加
        tk.Label(root, text="ラベル4").grid(row=2, column=1)


def main():
    root = tk.Tk()
    app = App(root)
    app.root.mainloop()


if __name__ == "__main__":
    main()

columnspanはデフォルトでは1が渡されているがこれを2にすることで2つ分のセルを結合したような状態となる。

実行結果は下記の通り。

f:id:shinshin86:20220101072845p:plain

ラベル3の位置が変わっていることが分かる。

同じように rowspan という属性もあり、これは縦方向のセルを結合するために利用される。

gridのオプションについて

  • column - ウィジェットを配置する列番号(0はじまり)
  • row - ウィジェットを配置する行番号(0はじまり)
  • columnspan - 横方向に結合する数(デフォルトは1)
  • rowspan - 縦方向に結合する数(デフォルトは1)

またgridのオプションには隙間(空白)を設定できるものもある。
(webで言うmarginとpadding)

ipadx, ipadyがpadding的なものとなり、padx, padyがmargin的な使い方となる。

実際にipadyでlabelの内側の隙間(padding)を広げたサンプル

import tkinter as tk

class App:

    def __init__(self, root):
        self.root = root
        root.title("grid サンプル")
        root.geometry("600x400")
        tk.Label(root, text="ラベル1", bg="green").grid(row=0, column=0, ipady=50) # 分かりやすく50に設定
        tk.Label(root, text="ラベル2", bg="coral").grid(row=0, column=1)
        tk.Label(root, text="ラベル3", bg="blue").grid(row=1, column=0)
        tk.Label(root, text="ラベル4", bg="red").grid(row=2, column=1)


def main():
    root = tk.Tk()
    app = App(root)
    app.root.mainloop()


if __name__ == "__main__":
    main()

すると下記のようになる。

f:id:shinshin86:20220101074506p:plain

gridのstickyオプションについて

stickyオプションを利用すると、指定した方向に対してグリッド内いっぱいにウィジェットが広がる。

具体的にどのような挙動をするか、まずはサンプルを用意する。

import tkinter as tk

class App:

    def __init__(self, root):
        self.root = root
        root.title("grid サンプル")
        root.geometry("600x400")
        tk.Label(root, text="ラベル1", bg="green").grid(row=0, column=0)
        tk.Label(root, text="ラベル2", bg="coral", width=50).grid(row=0, column=1)
        tk.Label(root, text="ラベル3", bg="blue").grid(row=1, column=0)
        tk.Label(root, text="ラベル4", bg="red").grid(row=2, column=1)


def main():
    root = tk.Tk()
    app = App(root)
    app.root.mainloop()


if __name__ == "__main__":
    main()

このコードを実行すると、下記のようなウィジェット配置となる。

f:id:shinshin86:20220101075048p:plain

次にこのサンプルの ラベル4 にstickyオプションを指定する。
指定内容は左右いっぱいに広げるという内容となる。

import tkinter as tk

class App:

    def __init__(self, root):
        self.root = root
        root.title("grid サンプル")
        root.geometry("600x400")
        tk.Label(root, text="ラベル1", bg="green").grid(row=0, column=0)
        tk.Label(root, text="ラベル2", bg="coral", width=50).grid(row=0, column=1)
        tk.Label(root, text="ラベル3", bg="blue").grid(row=1, column=0)
        tk.Label(root, text="ラベル4", bg="red").grid(row=2, column=1, sticky=tk.EW) # stickyを指定


def main():
    root = tk.Tk()
    app = App(root)
    app.root.mainloop()


if __name__ == "__main__":
    main()

すると、下記の通りの配置となる。

f:id:shinshin86:20220101075138p:plain

本来widthを指定していないラベル4がセルの左右いっぱいに広げられているのはstickyによるものだ。

例えば右に寄せて配置したければ sticky=tk.E と指定すれば良い。

このstickyで方向を指定する際のEとかWとかは東西南北から取られていると思われる。

  • E - 東、つまり右方向
  • W - 西、つまり左方向
  • N - 北、つまり上方向
  • S - 南、つまり下方向

上のサンプルに書いたように tk.EW と組み合わせでも記載できる。

2021年を振り返る

2021年も早いものでもう終わりなので、いくつかのトピックに絞って振り返っておこうと思う。

目次

自身の仕事について

諸事情により去年からしばらくニートを満喫していたが、今年から働き始めた。
会社を作ったり、会社役員とは別にフリーランスのエンジニアとしても仕事をしたりしている。

今年後半、仕事がらみでは主にReactを使ってフロントエンドをガッツリ触っていた。

最近は結構パフォーマンスが求められる領域での仕事もさせてもらっているので、処理のメモ化などに神経を使うことが多くなった。

仕事といえば、Webサービスの多言語対応も行った。
多言語対応については以前も実施したことがあり、個人でも多言語対応したアプリなどは開発していたので、こちらについては今までのノウハウをもとに割とスムーズに進めることができた。

自身の経験をもとにスムーズに業務を進められた、という経験ができたのは一つの自信になるし良かったと思う。

子育て

子育てについては今年から子供は保育園生となった。
そして保育園に通い始めてから驚くほどに体調を崩すようになった。

保育園に通い始めるまではほとんど風邪など引いたことがなかったのに、やはり外の環境で生活するようになると体調は崩しやすくなるようだ。
また、子供がもらってきた風邪を親がもらうということも多く、私も体調を崩すことが多かった。

そんな風邪を引くことが多めの保育園生活だったが、他人との共同生活を経験できるという点で保育園に通わせてよかったなと思った。

保育園に通い始めてからというもの、親が認識していない成長というものを垣間見ることも増えて、きっと外で様々な経験をしているのだなと思わせられることが多かった。

少し前に保育参観に行ってきたのだが、外の社会でたくましく生きている娘の姿を見ることができて、とても面白かった。

YouTubeチャンネルについて

実はYouTubeチャンネルを運営していたりする。といっても、あまり頻繁な更新はしていない。

動画作成は労力も大きく、一つの動画を作るのはなかなか骨が折れる。

そのくせ、結果も出にくい分野となっているのでこれはまあ大変。
(素人が動画を公開してもすぐにフィードバックを得られることは少ない、という面ではブログとにていると思ったりもする)

ただ年内中に一つの目標であったチャンネル登録者数100人を突破できたのは嬉しかった。来年は目指せ1000人で、収益化を目指したい。

音楽について

音楽についてはアウトプットは少なめだったかと思う。

今年の最初の方にBeagles名義で一枚アルバムを作ったり、Caramel Shipで新曲を何曲か作ったりした。

f:id:shinshin86:20210416213212p:plain
Beagles9枚目のフルアルバム mashi mashi

今も水面下でCaramel ShipやB/W名義の曲は何曲か作成中なので、そのうち発表できるかもしれない。

Google AdSenseの収益が伸びた年

Google AdSenseについてはそれなりに飛躍があった年となった。

こちらのブログ、Google AdSense経由で広告を出しているが、こちらの収益が今年は一気に延びた。

伸びたといっても微々たるものではあるが、これで飲みに行ったりゲームを購入したりはできるぐらいの収益が数ヶ月に一回入るようになっている。

今年に入ってからサイトの検索流入周りを意識するようになり、そちらに関する施策などを色々試していた。

いくつかの施策は結果がでて、それがこちらのブログの場合だと収益という形で目に見えるようになっている。

また他のサイトについても検索流入が増えていっている。

簡単にAMPサイトを構築できるHabanero Beeで構築した、とあるニッチなサイトが今月一気にはねて、こちらも収益化ができたのは嬉しいサプライズだった。
Habanero Beeで構築したサイトでも収益化が問題なく行えたのは、ツールとしても良いことだし、来年のやる気にもつながる。

f:id:shinshin86:20210722080053p:plain
Habanero Beeのロゴ

そういえばHabanero Beeをリリースしたのも今年だった。
Habanero Beeとは何?という方は下記のポストを見てみてください。

zenn.dev

こうやって振り返ると、サイト流入というキーワードについてよく考えていた一年だったかもしれない。

Habanero Beeの開発を行う上でAMP周りについてもよく調べた。

引き続き来年も検索流入周りについては意識をしていきたいと思う。

まとめ

というわけで、いくつかのトピックに絞って好き勝手に書き散らしてきた。

2021年を振り返る、という割には非常に断片的なトピックしか書いていない気もするが、まああまり考えすぎてもしょうがないのでここらへんで筆を止めようと思う。

来年もよろしくおねがいします。

node-fetchでError [ERR_REQUIRE_ESM]: Must use import to load ES Module:というエラーが出たときの対応について

node-fetchでError [ERR_REQUIRE_ESM]: Must use import to load ES Module:というエラーが出た

これについての明確な答えが下記のstack overflowにあった。

stackoverflow.com

node-fetchはv3からESM専用モジュールとなったよう。そのため require でインポートすることができない。
ESMに切り替えたくない場合はCommonJSとの互換性が保たれているv2を利用する必要があるようだ。

下記のコマンドで2系のnode-fetchをインストールする。

yarn add node-fetch@2

ちなみに本日時点(2021/12/29)で入ったversionは 2.6.6 でした。

PWAアプリにmaskableアイコンを追加する方法

Lighthouseで計測したところ下記のような要修正項目が現れた。

f:id:shinshin86:20211229210212p:plain

該当する下記のページを読んでみると、maskableアイコンとはどうやらすべてのAndroidバイスで美しく表示されるアイコンフォーマットらしい。
ちなみにマスキング可能なアイコンフォーマットに従わないPWAアイコンは白い背景が与えられるとのこと。

Manifest doesn't have a maskable icon

PWAアプリにmaskable icon を追加する方法

まずは下記のMaskable.app EditorにアクセスしてMaskableアイコンを作成する。

Maskable.app Editor

上のリンクをクリックするとこんな画面が現れる。

f:id:shinshin86:20211229210301p:plain
Maskable​.app Editor

Editorの使い方はなんとなくいじっていればわかりそうな感じだったので割愛する。

Export ボタンを押すとサイズを指定してダウンロードできる。

ひとまず上のドキュメントにならって 192pxのサイズを指定した。

webmanifestファイルの icons に下記の記述を追加する。

icons: [
  ・
  ・
  {
    "src": "/<画像格納先のパス>/maskable_icon_x192.png",
    "sizes": "196x196",
    "type": "image/png",
    "purpose": "any maskable"
  }
]

これで再びLighthouseを行うと、今度はOKになっていることが確認できた。

f:id:shinshin86:20211229210429p:plain

以上、備忘録でした。

tkinterでPython GUIプログラミング入門

PySimpleGUIは触っているが、その下のレイヤーで動くtkinter自体は触ったことがなかったので、このタイミングで入門してみようと思う。

一通りtkinterを使ってGUIアプリを作れるようになるところまでをこの記事内に書いていく。

長くなると思うので目次を要参照。

目次

環境

自身の試す環境は下記の通り。

参照するドキュメント

tkinterについて調べようとしたのだが、困ったことにまとまった公式のドキュメントが非常にわかりにくい or 古くて今でも有効なのかがよく分からなかった。

日本語でまとめられているドキュメントでPython 3系での記述もありのドキュメントを見つけたので、こちらを参照しながら一つずつ試してみようと思う。
(なお、途中で内容はアレンジしたり適宜飛ばしたりしていますのでご了承ください。気になる方は下記の記事もチェケラ!)

https://nnahito.gitbooks.io/tkinter/content/

ウィンドウだけを表示する

シンプルにウィンドウだけを表示する

import tkinter as tk
window = tk.Tk()
window.mainloop()

テキスト(Label)をウィンドウ上に表示させる

テキスト表示のサンプル

import tkinter as tk
window = tk.Tk()

label = tk.Label(text="ハローワールド")
label.pack()

window.mainloop()

一気に画面が小さくなった。。。

ウィンドウのサイズを指定

下記の方法(コメント参照)でウィンドウサイズを指定できる。

import tkinter as tk
window = tk.Tk()

# windowのサイズを指定
window.geometry("400x300")

label = tk.Label(text="ハローワールド")
label.pack()

window.mainloop()

ウィンドウタイトルを指定する

ウィンドウタイトルの指定方法は下記の通り。

import tkinter as tk
window = tk.Tk()

# window タイトルを指定
window.title("ハローワールド")

# windowのサイズを指定
window.geometry("400x300")

label = tk.Label(text="ハローワールド")
label.pack()

window.mainloop()

ラベル(Label)の内容を更新する

こちらについては別のポストとして書いた。

shinshin86.hateblo.jp

ここでは概要のみに留めるが、下記のようにすることでテキストを変更することが可能。

label["text"] = "変更後のテキスト"

ラベルの位置を指定する(pack)

ここまで何の説明もなく label.pack() というように packを用いてラベルを表示してきた。 このpackはラベルをGUI上にいい感じに要素を配置してくれる機能で、細かい指定はどうでもいいからいい感じにパーツを画面上に配置したい、というケースで重宝する。

ただ、この pack を利用する場合でも、ある程度位置を調整したいケースがある。

pack側では下記のようなオプションが用意されているので、そちらを用いて適宜対応していくような形となる。

anchor

配置可能なスペースに余裕がある場合、対象のパーツをどこに配置するかを指定できる。

例えば指定されたエリアの中で左寄せしたい、などのケースではこのオプションが有効。

指定可能な内容は以下の通り。
tk 内に定数が用意されているのでそちらを参照する形で指定するのが良い。

  • tk.CENTER - 中央
  • tk.W - 左寄せ
  • tk.E - 右寄せ
  • tk.N - 上寄せ
  • tk.S - 下寄せ
  • tk.NW - 左上
  • tk.SW - 左下
  • tk.NE - 右上
  • tk.SE - 右下

expand

親のウィジェットが大きくなった際に、一緒に大きくなるかどうかを指定できる expand=1という形で指定できる。 逆にあえて指定しない場合は 0

fill

スペースを埋めて表示するかどうかを指定できる。

例えば横方向に埋めたい場合、fill="x" と指定すると、親のウィジェットに対して対象のウィジェットが横方向に敷き詰められた状態で表示できる。

利用できるオプションは以下の通り。

  • fill="none" - 元のサイズを保持
  • fill="x" - 横に広がる
  • fill="y" - 縦に広がる
  • fill="both" - 縦横に広がる

padding系オプション

CSSで言う、いわゆるmargin・padding系の指定が下記となる。
以下のように隙間は数値で指定する(例では 1 を適用)

  • padx=1 外側の横の隙間を設定
  • pady=1 外側の縦の隙間を設定
  • ipadx=1 内側の横の隙間を設定
  • ipady=1 内側の縦の隙間を設定

side

使ってみた感じはCSSのfloatのような感じ。
どの方向に詰めていくかを指定できる。

  • side="top" - 上から詰めていく
  • side="left" - 左から詰めていく
  • side="right" - 右から詰めていく
  • side="bottom" - 下から詰めていく

packの指定については以上。

これらのオプションを使っても要件を満たせなさそうな場合は、次に書いていくplaceやgridなどを利用していくことになる。

ラベルの位置を指定する(place)

packはラベルをいい感じに配置してくれる機能だが、より細かく位置を指定したい場合は place()を利用するようだ。

import tkinter as tk
window = tk.Tk()

# window タイトルを指定
window.title("ハローワールド")

# windowのサイズを指定
window.geometry("400x300")

label = tk.Label(text="ハローワールド")
label.place(x=100, y=100)

window.mainloop()

数値で指定するというのは実際には難しいので、ここらへんは pack()を使って適宜対応していくのかどうなのか。

おそらくPySimpleGUIはここらへんの座標関係の処理もいい感じに行っていてくれているのだろう、と想像しながらも次に行く。

ラベルの位置を指定する(grid)

またpackplaceとは別に grid というものもある。

これはrowcolumn を用いて位置を指定することができる。

また sticky を用いることで左に寄せて配置などのようなことが行える。

gridについては下記のポストにまとめたので、こちらを参照してみてください。

shinshin86.hateblo.jp

入力フォームを作る

次はtkinterで入力フォームを作成する。

import tkinter as tk
window = tk.Tk()

# window タイトルを指定
window.title("ハローワールド")

# windowのサイズを指定
window.geometry("400x300")

label = tk.Label(text="あなたの気分を入力してください")
label.pack()

text_form = tk.Entry()
text_form.pack()

window.mainloop()

まあまあ と入力しようと思ったのに、困ったことに日本語入力にならない!

tkinterで日本語入力を行う方法(pyenv環境でも動くことを確認)

調べてみると、どうやらこれはTcl/Tkパッケージによるバグらしい、という記事を見た。
ちなみにこのバグに該当するバージョンが 8.5 のようだが、私の環境はまさにそれだった。

python -c "import tkinter;print(tkinter.TkVersion)"
# => 8.5

pyenvでインストールしたPythonmacOS 10.6以降のユーザでMacPythonインストーラを使っている方は該当するそうな。

で、どうすれば良いのか調べてみると下記のページに行き着いた。

www.python.org

結論から先に書くと、Pythonのversionを新しものにすれば、それに付随してついてくるTcl/Tkパッケージも新しいものになるので、新しいPythonを使えばOKとのこと。

私はpyenvを利用してPython環境を構築しているので、下記のようにPython3.10.1 をインストールした。

pyenv install 3.10.1

# 最近私はglobalで設定して使っていますが、環境ごとに分けたいならlocalにしておいてください
pyenv global 3.10.1

3.10.1を入れると、TkVersion8.6 になる。

python -c "import tkinter;print(tkinter.TkVersion)"
# => 8.6

これで日本語入力ができない問題は無事に解決。

問題なく日本語入力が行えている

入力フォームの値を取得して、ボタンを押したら入力内容をダイアログに表示する方法

次はtkinterを使って入力フォームの値を取得し、ボタンを押したらその内容をダイアログに表示する方法。

一気にやることを増やしてしまったが、まあ、より実践的な内容にシフトしていこうと思う。

import tkinter as tk
import tkinter.messagebox as tkm

window = tk.Tk()

# window タイトルを指定
window.title("ハローワールド")

# windowのサイズを指定
window.geometry("400x300")

# ボタンが押されたらこの関数が呼ばれる
def show_msg():
    # text_formの値を取得
    value = text_form.get()

    # ダイアログに表示
    tkm.showinfo("入力されたテキスト", value)


label = tk.Label(text="あなたの気分を入力してください")
label.pack()

text_form = tk.Entry()
text_form.pack()


# ボタンを設置(commandに実行時のイベントを設定)
button = tk.Button(text="入力値を表示", width=50, command=show_msg) 
button.pack()

window.mainloop()

これでボタンを押すとテキストフォームに入力された値をダイアログに表示することができる。

ボタンに設定したイベント内でそのまま引数を渡したい時

上のやり方だと呼び出されたshow_msg関数内で入力値を取得しているが、その値を関数に引数として渡したいこともある。

そういうときは下記のように command=lambda と書いてlambda式を使うことで実現できるようだ。

import tkinter as tk
import tkinter.messagebox as tkm

window = tk.Tk()

# window タイトルを指定
window.title("ハローワールド")

# windowのサイズを指定
window.geometry("400x300")

# ボタンが押されたらこの関数が呼ばれる
def show_msg(value):
    # ダイアログに表示
    tkm.showinfo("入力されたテキスト", value)


label = tk.Label(text="あなたの気分を入力してください")
label.pack()

text_form = tk.Entry()
text_form.pack()


# ボタンを設置(引数を渡す場合はlambdaを使う)
button = tk.Button(text="入力値を表示", width=50, command=lambda: show_msg(text_form.get()))
button.pack()

window.mainloop()

tkinterで使えるダイアログの種類

ダイアログには様々な種類があるので、そちらを画像とともに載せておく。

下記のようなコードを用意してそれぞれ試した。

import tkinter as tk
import tkinter.messagebox as tkm

window = tk.Tk()
window.title("ダイアログサンプル")
window.geometry("400x300")

def show_info():
    tkm.showinfo("普通のダイアログ", "テストメッセージ")

def show_warn():
    tkm.showwarning("警告ダイアログ", "テストメッセージ")

def show_error():
    tkm.showerror("エラーダイアログ", "エラーメッセージ")

# YESがクリックされたら戻り値がTrue、NOならFalse
def show_askyesno():
    print(tkm.askyesno("YES or NO で答えられるダイアログ", "テストメッセージ"))

# リトライがクリックされたら戻り値がTrue、キャンセルならFalse
def show_askretrycancel():
    print(tkm.askretrycancel("Retry or Cancel で答えられるダイアログ", "テストメッセージ"))

# Yesがクリックされたら戻り値がyes、Noならno
def show_askquestion():
    print(tkm.askquestion("OK or NO で答えられるダイアログ", "テストメッセージ"))

# OKがクリックされたら戻り値がTrue、CancelならFalse
def show_askokcancel():
    print(tkm.askokcancel("OK or Cancel で答えられるダイアログ", "テストメッセージ"))

info_btn = tk.Button(text="Info", width=20, command=show_info)
info_btn.pack()
warn_btn = tk.Button(text="Warn", width=20, command=show_warn)
warn_btn.pack()
error_btn = tk.Button(text="Error", width=20, command=show_error)
error_btn.pack()
askyesno_btn = tk.Button(text="Yes/No", width=20, command=show_askyesno)
askyesno_btn.pack()
retry_btn = tk.Button(text="Retry/Cancel", width=20, command=show_askretrycancel)
retry_btn.pack()
question_btn = tk.Button(text="OK/No", width=20, command=show_askquestion)
question_btn.pack()
ok_btn = tk.Button(text="OK/Cancel", width=20, command=show_askokcancel)
ok_btn.pack()

window.mainloop()

普通のダイアログ

普通のダイアログ

警告ダイアログ

警告ダイアログ

エラーダイアログ

エラーダイアログ。だが、見た目がそこまでエラーっぽくない...

Yes/Noダイアログ

Yesがクリックされたら戻り値がTrue、NoならFalse

Retry/Cancelダイアログ

Retryがクリックされたら戻り値がTrue、CancelならFalse

Yes/Noダイアログ(askquestion)

Yesがクリックされたら戻り値がyes、Noならno

OK/Cancelダイアログ

OKがクリックされたら戻り値がTrue、CancelならFalse

tkinterを使ってログ出力エリアにログを出力する。

tkinterのログについては下記のポストがとても参考になった。

beenje.github.io

こちらの内容を参考にさせていただきつつ、実際にログ出力のサンプルとして書いてみたコードが下記となる。

import queue
import logging
import signal
import tkinter as tk
from tkinter.scrolledtext import ScrolledText
from tkinter import ttk, VERTICAL, HORIZONTAL, N, S, E, W

logger = logging.getLogger(__name__)


class QueueHandler(logging.Handler):
    def __init__(self, log_queue):
        super().__init__()
        self.log_queue = log_queue

    def emit(self, record):
        self.log_queue.put(record)


class ConsoleUi:
    def __init__(self, frame):
        self.frame = frame

        # ScrolledTextウィジェットを作成する
        self.scrolled_text = ScrolledText(frame, state="disabled", height=12)

        # gridもpackやplaceと同じようにウィジェットの配置に関する関数
        self.scrolled_text.grid(row=0, column=0, sticky=(N, S, W, E))

        # フォントの設定
        self.scrolled_text.configure(font="TkFixedFont")

        # タグ名に対応するオプションを設定する(例: CRITCALが指定された場合、赤文字の下線ありで表示される)
        self.scrolled_text.tag_config('INFO', foreground='black')
        self.scrolled_text.tag_config('DEBUG', foreground='gray')
        self.scrolled_text.tag_config('WARNING', foreground='orange')
        self.scrolled_text.tag_config('ERROR', foreground='red')
        self.scrolled_text.tag_config('CRITICAL', foreground='red', underline=1)

        # queueを用いてlogging handlerを作成する
        self.log_queue = queue.Queue()
        self.queue_handler = QueueHandler(self.log_queue)

        # ログのフォーマットの設定
        formatter = logging.Formatter('%(asctime)s: %(message)s')
        self.queue_handler.setFormatter(formatter)

        # handlerをloggerに追加
        logger.addHandler(self.queue_handler)

        # queueからのメッセージをポーリングしていく
        # after関数を用いて、100msecごとにpoll_log_queueを実施
        self.frame.after(100, self.poll_log_queue)

    def display(self, record):
        # state="normal"にしないと書き込みが行えない。そのため一時的にnormalを設定
        self.scrolled_text.configure(state="normal")

        msg = self.queue_handler.format(record)
        
        # insert関数を用いてscrolled_textにログを渡す。3つめの引数にはタグを渡している(INFO, WARNINGなど)
        self.scrolled_text.insert(tk.END, record.levelname + ":" + msg + "\n", record.levelname)

        # ユーザが編集できないように再び"disabled"に戻す
        self.scrolled_text.configure(state='disabled')

        # 下にオートスクロール
        self.scrolled_text.yview(tk.END)

    def poll_log_queue(self):
        # 表示するメッセージがキューに存在するかどうかを100msecごとに確認する
        while True:
            try:
                record = self.log_queue.get(block=False)
            except queue.Empty:
                break
            else:
                self.display(record)
        self.frame.after(100, self.poll_log_queue)


class InputLogUi:

    def __init__(self, frame):
        self.frame = frame
        tk.Label(self.frame, text="出力したいログメッセージを入力してください").grid(column=0, row=1, sticky=W)
        log_form = tk.Entry(self.frame)
        log_form.grid(column=0, row=2, sticky=W)
        tk.Button(self.frame, text="DEBUGログ", width=10, command=lambda: self.debug_log(log_form.get())).grid(column=0, row=3, sticky=W)
        tk.Button(self.frame, text="INFOログ", width=10, command=lambda: self.info_log(log_form.get())).grid(column=0, row=4, sticky=W)
        tk.Button(self.frame, text="WARNログ", width=10, command=lambda: self.warn_log(log_form.get())).grid(column=0, row=5, sticky=W)
        tk.Button(self.frame, text="ERRORログ", width=10, command=lambda: self.error_log(log_form.get())).grid(column=0, row=6, sticky=W)
        tk.Button(self.frame, text="CRITICALログ", width=10, command=lambda: self.critical_log(log_form.get())).grid(column=0, row=7, sticky=W)
    
    def debug_log(self, log_msg):
        level = logging.DEBUG
        logger.log(level, log_msg)

    def info_log(self, log_msg):
        level = logging.INFO
        logger.log(level, log_msg)
    
    def warn_log(self, log_msg):
        level = logging.WARNING
        logger.log(level, log_msg)

    def error_log(self, log_msg):
        level = logging.ERROR
        logger.log(level, log_msg)

    def critical_log(self, log_msg):
        level = logging.CRITICAL
        logger.log(level, log_msg)




class App:

    def __init__(self, root):
        self.root = root
        root.title("Sample Logging Handler")
        root.columnconfigure(0, weight=1)
        root.rowconfigure(0, weight=1)

        # PanedWindowを作成
        vertical_pane = ttk.PanedWindow(self.root, orient=VERTICAL)
        vertical_pane.grid(row=0, column=0, sticky="nsew")
        horizontal_pane = ttk.PanedWindow(vertical_pane, orient=HORIZONTAL)
        vertical_pane.add(horizontal_pane)

        # Consoleフレームを作成
        console_frame = ttk.Labelframe(horizontal_pane, text="ログコンソール")
        console_frame.columnconfigure(0, weight=1)
        console_frame.rowconfigure(0, weight=1)
        horizontal_pane.add(console_frame, weight=1)

        input_log_frame = ttk.Labelframe(vertical_pane, text="ログ入力フォーム")
        vertical_pane.add(input_log_frame, weight=1)

        self.console = ConsoleUi(console_frame)
        self.input_log = InputLogUi(input_log_frame)

        ### アプリの終了に関する処理
        # 終了イベント(WM_DELETE_WINDOW)をquit関数に置き換え
        self.root.protocol('WM_DELETE_WINDOW', self.quit)
        # ctrl + qでquit関数を呼び出す
        self.root.bind('<Control-q>', self.quit)
        # SIGINTシグナルが送られたらquit関数
        signal.signal(signal.SIGINT, self.quit)


    def quit(self, *args):
        logging.log(logging.INFO, "アプリを終了します")
        self.root.destroy()


def main():
    logging.basicConfig(level=logging.DEBUG)
    root = tk.Tk()
    app = App(root)
    app.root.mainloop()


if __name__ == "__main__":
    main()

こちらのコードを実行してみると、下記のようなウィンドウが起動する。
フォームにログメッセージを入力して各ボタンを押下することで、対応したログ出力を再現できる。

ちなみにこのログ出力のサンプルコードを書くにあたって、tkinterに関するその他の機能についても同時に学ぶことができたので、そちらの内容についても書いていこうと思う。

Queueを用いてログ出力を行う

これは上に貼ったポストに書いてあったものだが、スレッド間でデータを共有するためにQueueが利用されている。

class QuereHandler(logging.Handler):
    def __init__(self, log_queue):
        super().__init__()
        self.log_queue = log_queue

    def emit(self, record):
        self.log_queue.put(record)
・
・
・
        # queueを用いてlogging handlerを作成する
        self.log_queue = queue.Queue()
        self.queue_handler = QueueHandler(self.log_queue)

ハンドラー自体はメッセージをキューに入れるだけとなっており、キューからメッセージをポーリングして、ログを出力させるというような設計になっている。

キューからログメッセージを取得して表示させる部分については下記の poll_log_queue 関数で行われている。

def poll_log_queue(self):
        # 表示するメッセージがキューに存在するかどうかを100msecごとに確認する
        while True:
            try:
                record = self.log_queue.get(block=False)
            except queue.Empty:
                break
            else:
                self.display(record)
        self.frame.after(100, self.poll_log_queue)

ScrolledTextに書き込みを行う方法

今回ScrolledTextクラスを用いてログを表示させているが、ScrolledTextには state という状態を管理する属性があり、こちらが normal のときでしか書き込みを行うことができない。
だが、normal のままにするとログ表示箇所にユーザが自由に書き込みできてしまうため、普段はユーザが書き込みできないように state=disabled を指定しておく必要がある。

よって、下記のように書き込み処理を行う間だけ一時的にstateを変更している。

下記はログの出力処理を行うdisplay関数。

    def display(self, record):
        # state="normal"にしないと書き込みが行えない。そのため一時的にnormalを設定
        self.scrolled_text.configure(state="normal")

        msg = self.queue_handler.format(record)
        
        # insert関数を用いてscrolled_textにログを渡す。3つめの引数にはタグを渡している(INFO, WARNINGなど)
        self.scrolled_text.insert(tk.END, record.levelname + ":" + msg + "\n", record.levelname)

        # ユーザが編集できないように再び"disabled"に戻す
        self.scrolled_text.configure(state='disabled')

        # 下にオートスクロール
        self.scrolled_text.yview(tk.END)

ScrolledTextにタグを設定する方法

また、このScrolledTextにはタグを設定することが可能で、下記のように指定したタグに応じた文字の装飾などを行うことができる。

        # タグ名に対応するオプションを設定する(例: CRITCALが指定された場合、赤文字の下線ありで表示される)
        self.scrolled_text.tag_config('INFO', foreground='black')
        self.scrolled_text.tag_config('DEBUG', foreground='gray')
        self.scrolled_text.tag_config('WARNING', foreground='orange')
        self.scrolled_text.tag_config('ERROR', foreground='red')
        self.scrolled_text.tag_config('CRITICAL', foreground='red', underline=1)

このように tag_config関数でタグを設定した場合、scrolled_textに対して出力処理を行う insert 関数内の第3引数に指定したタグを与えれば良い。

        # insert関数を用いてscrolled_textにログを渡す。3つめの引数にはタグを渡している(INFO, WARNINGなど)
        self.scrolled_text.insert(tk.END, record.levelname + ":" + msg + "\n", record.levelname)

afterを用いて継続的に処理を実施

tkinterにはafterメソッドというものが用意されており、こちらも用いることで、指定した処理を一定間隔で定期的に実行することが可能となる。

今回の場合、下記のようなログのポーリング処理で利用している。

        # queueからのメッセージをポーリングしていく
        # after関数を用いて、100msecごとにpoll_log_queueを実施
        self.frame.after(100, self.poll_log_queue)

追記:ログ出力のサンプルについて

上のコードをリファクタリングして、GitHubにあげた。

github.com

表示させたログ(ScrolledText)の内容をクリップボードに貼り付ける

上のログサンプルを進化させて、ボタンを押したら表示されているログをクリップボードに貼り付けるようにした。

クリップボードを利用するには、 clipboard_append を利用すれば良いようだが、ScrolledTextの値については scrolled_text.get() だけでは取得できない。

こちらについては下記のstack overflowが参考になった。

stackoverflow.com

どうやら下記のように引数を渡すことで値を読み取ることができた。
クリップボードにコピーする部分も含めて下記のように書くことで処理は実現可能。

    def copy_clipboard(self):
        self.scrolled_text.clipboard_append(self.scrolled_text.get("1.0", tk.END))
        tkm.showinfo("Copy to clipboard", "Copied log to clipboard.")

実際に上に書いたログ出力サンプルにクリップボードへのコピー機能を足した際のコミットがこちら。

github.com

frameを跨いだ場合の関数の渡し方はどうするのが良いのかまだ理解できていないが、ひとまずこれで別フレームで定義したクリップボードコピーボタンに対して、ScrolledTextで表示されている最初から最後までの文章をすべてクリップボードにコピーできるようになった。

表示させたログ(ScrolledText)の内容を削除する

上に続いて、今度はScrolledTextの中身を削除する方法。

再び、上に書いたログ出力サンプルに実際に機能を足したので、そちらのコードをこちらに記載する。

    def clear_log(self):
        if tkm.askyesno("Clear log", "Are you sure you want to delete the log?") is True:
            self.scrolled_text.configure(state='normal')
            self.scrolled_text.delete("1.0", tk.END)
            self.scrolled_text.configure(state='disabled')

このようにscrolled_text.delete("1.0", tk.END) とし、値を取得するときと同様に範囲を指定する必要があるが、全て取得するのであれば delete("1.0", tk.END)で問題ない。

なお、ScrolledTextはあくまでログ出力の表示コンソールとして設定しているもののため、普段はユーザが内容を変更できないように self.scrolled_text.configure(state='disabled') を指定して、編集無効としている。

そのため値の削除の前に、 state='normal' を指定して値を編集可能な状態にする必要がある。

これは ScrolledTextに書き込みを行う方法 にも書いた内容と同じである。

実際に機能追加した際のコミット内容は下記となる。

github.com

ログの表示がリアルタイムに反映されない時

上に書いたログ表示に関する続き。

例えばtkinterからボタンを押して下記のような関数を実行した場合。

def all_log(self, log_msg):
    log_levels = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]

    # ここでログ出力を行っているが、これはリアルタイムに反映されない
    for level in log_levels:
        self.log(level, log_msg)
        sleep(1)

この self.log という関数が実行されると、tkinterのログ出力用の窓にログが表示されていく、という仕組みだとして、この all_log関数をtkinterのボタン経由でそのまま実行してもログはリアルタイムに表示されない。

ログが表示されるタイミングは all_log 関数がすべて実行され終えたあとに、まとめてログが表示されるような流れとなる。

これはこの関数が実行されている間はGUIの描画処理は行われないためである。

そのため、このような処理でリアルタイムにログの描画も行うためにはこの all_log 関数を別スレッドで実行していくようにする。

具体的には下記のようなコードに書き換えると、この関数自体は別スレッドでの処理となるため、ログ表示に関するGUI側の処理も関数処理と並行して行われるようになる。

import threading
・
・
・
def all_log(self, log_msg):
    def thread_all_log():
        log_levels = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]

        for level in log_levels:
            self.log(level, log_msg)
            sleep(1)

     thread = threading.Thread(target=thread_all_log)
     thread.start()

なお、実際にこちらの修正はGitHub上でも確認可能となっている。

Add thread · shinshin86/sample-tkinter-log-output@e9b5529 · GitHub

tkinter上からSeleniumを起動させる

上のスレッドの話と関連する話となるが、例えばtkinter上でボタンを押すとSeleniumが起動して何らかのスクレイピング処理が動くものとする。

その際に普通にSeleniumを用いた処理を実行していると、実行している間はGUIが操作不能となる。

これは上にも書いたログの描画と同様に、別の処理の実行中に同時にGUIの描画ができないためとなる。

そのためこのようなケースに置いてもスレッドを別途作成して、Seleniumによる処理を別スレッドでの処理するようにすることで、Seleniumを用いた処理が動いている間もGUIの操作が行えるようになる。

サンプルコード的には上に書いたログ表示のものとそれほど変わらないものとなるので割愛するが、そのうち時間を見つけてこちらに関するサンプルコードも追記するかもしれない。

tkinterとsqlite3を組み合わせてアプリを作る

sqlite3(SQLite)と組み合わせてtkinterGUIアプリを作ることも可能。

こちらの内容については別ポストとして書いた。

shinshin86.hateblo.jp

PySimpleGUIとtkinter、それぞれでHTMLのURLリンク(ハイパーリンク)をテキストフィールドに作る方法

目次

PySimpleGUIでHTMLのURLリンク(ハイパーリンク)をテキストフィールドに作る方法

PySimpleGUIで作ったテキストフィールドにテキストリンクを付与したいと考えたが、それを実現するのは思いの外かんたんではなさそうだった。

少し調べてみると、まさに私が実現したいことが書かれているstack overflowを見つけたので、そちらのリンクを貼る。

stackoverflow.com

ここに書かれている通りで、テキストに対してイベントを設置してそれをクリックしたらブラウザを起動して対象のURLにアクセスさせる、という手順を踏むのが一番実現的なようだ。

Pythonでブラウザを起動する方法

Pythonでは webbrowserを利用することでかんたんにブラウザを用いて特定のURLにアクセスできる。

シンプルなサンプルコードを下記に載せる。

import webbrowser
url = "https://www.google.com/"
webbrowser.open(url)

このコードを実行すると、実行したPCのデフォルトブラウザが起動してGoogleにアクセスする。

PySimpleGUIの画面上にハイパーリンクを実装したサンプル

では、本題。と言っても上に貼ったstack overflowを参考にしたものではあるが。

import webbrowser
import PySimpleGUI as sg

# フォントをリンクのように下線をつけた状態にする
font = ('Courier New', 16, 'underline')

# URLは'URL <url>' というように予め構造を決めて設定している
layout = [
    [sg.Text("Googleへアクセス", enable_events=True, font=font, key="URL https://www.google.com/")],
    [sg.Text("Appleへアクセス", enable_events=True, font=font, key="URL https://www.apple.com/")]
]

window = sg.Window("Hyper Link Sample", layout, size=(250, 100), finalize=True)

while True:
    event, values = window.read()
    print(event, values)

    if event == sg.WINDOW_CLOSED:
        break
    elif event.startswith("URL "):
        url = event.split(' ')[1]
        print("アクセスするURL: ", url)
        webbrowser.open(url)


window.close()

上のサンプルコードを実行すると、下記のようなGUIアプリが立ち上がる。

f:id:shinshin86:20211227075836p:plain

テキストをクリックすると、デフォルトブラウザが起動してそれぞれのページにアクセスする。

テキストに対してイベントを付与する

上にも書いたがハイパーリンクの実装についてはテキストに対してイベントを付与して、そのテキストをクリックした際に対象のイベントを実行するという仕組みとなる。

URLの取得については split で抽出するという形が取られており、今回の場合、URL <url> という文字列を決め事として実装する必要があるが、そもそもPySimpleGUIでハイパーリンク的なものを設定したい場合というのはアプリの提供側で、例えばGUIアプリのサポートページのリンクを付与したい、というようなユースケースがメインになりそうな気がしているので、これで事足りそうではある。

というわけでPySimpleGUIでハイパーリンクを設定する方法に関する備忘録でした。

tkinterでHTMLのURLリンク(ハイパーリンク)をテキストフィールドに作る方法

ここからは追記の内容となる。

最近tkinterを用いてGUIアプリを作成している。PySimpleGUIに比べるとtkinterのほうが自由度も高めな分、どうしても実装に必要な工数もかかるが、 キャッチアップは意外とそこまでかからなかった印象。

そんなtkinterでも同様にハイパーリンクのテキストを作成してみたので、そのやり方をこちらに記載する。

といっても、PySimpleGUIで書いた内容と仕組み的には同じ。

サンプルコードは下記。

import tkinter as tk
from tkinter import font
import webbrowser

root = tk.Tk()
root.geometry("600x400")
root.title("Hyper Link Sample")

link_text_font = font.Font(size=16, underline=True)

google_link_text = tk.Label(root, text="Googleへアクセス", font=link_text_font)
google_link_text.pack()

apple_link_text = tk.Label(root, text="Appleへアクセス", font=link_text_font)
apple_link_text.pack()

google_link_text.bind("<Button-1>", lambda e:webbrowser.open("https://www.google.com/"))
apple_link_text.bind("<Button-1>", lambda e:webbrowser.open("https://www.apple.com/"))

root.mainloop()

こちらを実行すると、下記のようなウィンドウが立ち上がる。

ハイパーリンクテキストをクリックすると、PySimpleGUIと同様にデフォルトブラウザが立ち上がり、それぞれのサイトにアクセスする。

f:id:shinshin86:20220110082530p:plain
tkinterで実装したハイパーリンクテキストのサンプル

tkinterで下線を引いたテキストの作成方法

tkinterでテキストのフォントを設定する際はtkinter.font.Font クラスのインスタンスを作成して利用することで設定可能だった。

公式ドキュメントのリンクはこちら。

tkinter.font — Tkinter font wrapper — Python 3.10.2 documentation

コード的には下記となる。

link_text_font = font.Font(size=16, underline=True)

このインスタンスをLabel作成時に付与すれば指定したfontで設定される。

google_link_text = tk.Label(root, text="Googleへアクセス", font=link_text_font)

tkinterハイパーリンクテキストをクリックした際にサイトにアクセスさせる方法

このやり方はPySimpleGUIで実践したものと同様の仕組みとなり、webbrowser.open関数を利用する。

作成したLabelに対してbindで左クリックした際のイベントを設定し( <Button-1> は左クリックを表すようだ)、クリックされたタイミングでwebbrowser.open関数を用いて指定したサイトにアクセスするようにしている。

google_link_text.bind("<Button-1>", lambda e:webbrowser.open("https://www.google.com/"))
apple_link_text.bind("<Button-1>", lambda e:webbrowser.open("https://www.apple.com/"))

ハイパーリンクの設定方法については以上となる。

インストールされているChromeのversionに応じたchromedriverを勝手にインストールしてくれるwebdriver-managerが便利

SeleniumからChromeを扱う時、現在PCにインストールされているChromeのversionに応じたchromedriverを自身でインストールしておく必要がある。

ただ、Chromeのversionは定期的に新しくなるため、Chromeのversionが更新された際には対応するChromeのdriverをセットアップする必要がある。

当然ながらこの作業は面倒である。
というわけで、この課題を解決するためのツールとして今回備忘録として残す webdriver-manager が登場する。
(なお、似たようなツールは他にもあるようだが、私はまだこれしか試していない)

github.com

試してみた際のコードサンプルを下記に貼る。

from selenium import webdriver
from webdriver_manager.chrome import ChromeDriverManager
from time import sleep

# ここで対応するchromedriverをインストールして `webdricer.Chrome` に渡している
driver = webdriver.Chrome(ChromeDriverManager().install())
driver.get('https://www.google.com/')

sleep(5)

search_form = driver.find_element_by_name("q")
search_form.send_keys("積乱雲")
search_form.submit()

sleep(5)

driver.save_screenshot("./screenshot.png")
driver.quit()

このように既存のコードをほとんど変えることなく適用できる点が良いと思った。

ちなみに下記のようにoptionsを渡しているケースもある。

options = webdriver.ChromeOptions()

# options.add_argumentを用いて設定
# ・
# ・

driver = webdriver.Chrome(options=options)

その場合は下記のように第一引数に ChromeDriverManager().install() を渡すようにする。

options = webdriver.ChromeOptions()

# options.add_argumentを用いて設定
# ・
# ・

driver = webdriver.Chrome(ChromeDriverManager().install(), options=options)

これらのコードを実行すると、下記のようなログが吐き出される。

====== WebDriver manager ======
Current google-chrome version is 96.0.4664
Get LATEST chromedriver version for 96.0.4664 google-chrome
There is no [mac64] chromedriver for browser  in cache
Trying to download new driver from https://chromedriver.storage.googleapis.com/96.0.4664.45/chromedriver_mac64.zip
Driver has been saved in cache [/Users/{ユーザ名(※マスクしている)}/.wdm/drivers/chromedriver/mac64/96.0.4664.45]

ログにあるとおり現在のChromeのバージョンは 96.0.4664 で、こちらに対応するchromedriverをダウンロードしている。

ダウンロードしたdriverは ~/.wdm/drivers/chromedriver/mac64/96.0.4664.45 に入り、こちらのディレクトリ内に該当する chromedriverとダウンロードしてきたZIPと思われる driver.zip が格納されているのを確認した。

また、~/.wdm/drivers.json ではダウンロードしてきたchromedriverに関する情報を管理していると思われる。

ここらへんの詳細はソースコード、またはドキュメントに記載があればそちらを確認してみるのが良さそうだ。

リポジトリのREADMEには設定に関する内容も記載されていたので、興味がある方はそちらも一読してみるとよいだろう。