最近、Pythonスクリプト組んだり、プライベートでも勉強がてらDeep LearningをC++で実装してみたりと、CLIベースで操作するコードはよく書くものの、そういえばGUIで何かタスクをやらせるというものは作ったことがないなと思い至りました。
折角なので、イベント駆動のプログラミングの勉強を兼ねてGUIアプリを何か作ろうと思い、とりあえずPythonでメモ帳的なものを作ってみました。
その際、PySimpleGUIというライブラリを使って作ったのですが、見た目とかちょっとした機能にこだわるとバックエンドのライブラリであるtkinterの操作を要求されたりと、結構ハマりどころがあったので備忘録がてらまとめます。
また、Pythonので作成したコードから Pyinstaller というモジュールを使用するとexe化ができるなど、ターミナル操作に慣れていない人でも使えるようにパッケージングするところまでやってみたので、そこのノウハウもまとめようと思います。思いのほかTipsだけで記述量が増えてしまったので、アイコンの設定・exe化のノウハウについては後日別の記事にまとめます。
…Pythonのスクリプトをexe化したら簡単なプログラムでも10MB行くとか言っちゃダメです
目次
- 1 PySimpleGUIとは?
- 2 今回作ったもの
- 3 ハマったポイントと解決策
- 3.1 メモ帳の文章入力部分を生成する方法
- 3.2 入力部分を画面いっぱいに拡張する方法
- 3.3 ウインドウのサイズを可変にし、入力ボックスもサイズに合わせて可変にする方法
- 3.4 sg.FileSaveAsで作成したボタンからファイルパスを取得する方法
- 3.5 Menubarで「SaveAs」というイベントを発生させたときに、ファイル選択画面を表示させる方法
- 3.6 機能だけを使いたいボタンを、ウィンドウに表示させずlayoutに組み込む方法
- 3.7 フォント情報一覧の取得
- 3.8 undo / redo の実装
- 3.9 Windowを”X”ボタンで閉じるときに、確認のポップアップを表示させる方法
- 3.10 クリップボードへのコピー・内容取得・削除の方法
- 3.11 sg.Multilineの文章で選択している範囲を取得し、その部分だけ削除する方法
- 3.12 sg.Multilineの文章中のどこにカーソルがあるかを表示する方法
- 4 まとめ
PySimpleGUIとは?
PySimpleGUIとは、pythonでGUIを作るためのライブラリです。
pythonでGUIを作るためのライブラリは他にtkinterやPyQtなどが存在するのですが、それらをラッピングしてより少ない記述量でプログラムを書けるようにしたものがPySimpleGUIです。
とりあえず動くGUIアプリケーションを作る、という意味では使いやすくてオススメのライブラリです。
なお、使いやすいということは融通が利きにくいということで、細かい部分をチューニングするにはバックエンドで動作しているライブラリの使い方にまで踏み込む必要があります。
今回はメモ帳を作るにあたって一部tkinterの知識を要求されたので、それについても書いていきます。
ちなみにPySimpleGUIのライセンスはLGPL 3.0です。
作成したスクリプトのexe化まで紹介しますが、これを再頒布するときはソースコードの公開が必要になります。
これを基に良いアプリを作っても、商用でバイナリだけ配布するという真似はできないわけですね。残念。
今回作ったもの
こんな感じの見た目のアプリです。
機能としては
- テキストの編集・保存
- 右クリックで Redo / Undo / Copy / Paste / Cut の操作が行える
- カーソル位置のステータスバー上への表示
- フォントの変更機能
- メモ帳を閉じる際の確認画面表示
といったところになります。
ソースコードはgithubに公開していますので、全体像を見たい方はそちらを参照ください。
コード自体は350行程度の小規模なものです。
ハマったポイントと解決策
以下、メモ帳の機能を実装するにあたり結構ハマったポイントとその解決策についてまとめていきます。
なお、PySimpleGUIはsgという名前でimportしているものとします。
また、PySimpleGUIのバックエンドはtkinterのものを前提とします。
メモ帳の文章入力部分を生成する方法
まずは基本中の基本。sg.Multiline()
を用いればOK。
tkinterのモジュールとしては、tkinter.scrolledtext.ScrolledText
のインスタンスが生成されます。
import PySimpleGUI as sg
layout = [[sg.Multiline(key="multiline")]]
window = sg.Window("title", layout)
while True:
event, values = window.read()
if event == sg.WIN_CLOSED:
break
elif ...:
### 他イベント処理 ###
window.close()
最も基本的な流れですね。これで表示される内容は以下のような感じになります。
入力部分を画面いっぱいに拡張する方法
2箇所調整する必要があります。
まず、sg.Window
のコンストラクタでmargins=(0,0)
を設定し、各ウィジェットの配置に余白がないようにします。
この時点で以下のようになります。
まだ微妙に上下左右の余白が残っているので、次はsg.Multiline()
のコンストラクタで、pad=((0,0),(0,0))
を指定します。
これで以下のような画面になります。
以上を反映したコードが以下になります。
import PySimpleGUI as sg
sg.theme("SystemDefault1")
layout = [[sg.Multiline(key="multiline", pad=((0,0),(0,0)))]] # pad=((0,0),(0,0))を追加
window = sg.Window("title", layout, margins=(0,0)) # margins=(0,0)を追加
while True:
event, values = window.read()
if event == sg.WIN_CLOSED:
break
### 他イベント処理 ###
window.close()
ウインドウのサイズを可変にし、入力ボックスもサイズに合わせて可変にする方法
上記のコードではウィンドウのサイズを変更することができず不便です。
自由にサイズを変更するためにはsg.Window
のコンストラクタにresizable=True
を指定します。
また、これではsg.Multiline
の入力部分が自動でサイズ変更されないため、以下のコードのように設定を行います。
なお、ここで紹介する方法はsg.Window
内で生成されるtkinterのウィジェットをPySimpleGUIのAPIから操作する方法になるので、sg.Window
内でウィジェットの生成処理を行う必要があります。具体的には、sg.Window.read()
が呼び出されるか、sg.Window
のコンストラクタにfinalize=True
を設定するかのいずれかになります。ここでは、イベントループに入る前に設定を行うので、finalize=True
を指定するコードを紹介します。
import PySimpleGUI as sg
sg.theme("SystemDefault1")
layout = [[sg.Multiline(key="multiline", pad=((0,0),(0,0)))]]
window = sg.Window("title", layout, margins=(0,0), resizable=True, finalize=True)
window["multiline"].expand(expand_x=True, expand_y=True) # ウィンドウサイズに応じて入力エリアを可変に
while True:
event, values = window.read()
if event == sg.WIN_CLOSED:
break
### 他イベント処理 ###
window.close()
これで、以下のようにウィンドウを広げることが可能です(広げている様子のキャプチャではないです)。
補足ですが、layout
に格納しているPySimpleGUIのウィジェットに対応するtkinterのWidgetはsg.Window
のインスタンスにおいてwindow["Widgetのkey"].Widget
に格納されています。
後ほど出てきますが、tkinterのWidgetを直接操作する場合はこのようにするのでご注意ください。
sg.FileSaveAsで作成したボタンからファイルパスを取得する方法
PySimpleGUIにはファイルを保存するためのボタン作成のAPIが提供されています。
しかし、そのボタンを押した後にポップアップした画面で指定したファイルへのパスをプログラムでどのように取得するのかは意外と情報が落ちていません。
PySimpleGUIの枠組みでは、メインのループの最初にwindow.read()
でイベント・各ウィジェットから得られる値を取得しますが、デフォルトの設定では「”FileSaveAs”のボタンが押された」というイベントが発生しません。したがって、この場合はパスの値が格納されていないのでプログラムにその内容が反映されません。
これについてはenable_events=True
を設定することになります。
こうすることでイベントを取得することができるので、問題なくイベントループで処理を行えます。
具体的には以下のような形ですね。
save_as_button = sg.FileSaveAs(key="saveas", enable_events=True)
event, values = window.read()
if event == "save":
savepath = None
if values["saveas"] != "":
savepath = values["saveas"]
# -------------------------
# 何らかの保存処理
# -------------------------
break
これで、ポップアップした画面から取得されたファイルパスの情報がプログラム内に取り込めます。
2つ目以降のウィンドウで同様の処理を行いたい場合
なお、紹介した方法は最初のウィンドウでの話であり、ウィンドウから別のウィンドウを作成した場合(ポップアップなど)には同様にしてもイベントが取得できないという現象が起きます(PySimpleGUIの仕様?)
この現象の対応策としては、「無理やりイベントを発生させる」というものになります。
具体的には、window.read()
の引数にtimeout=0
を与え、常に__TIMEOUT__
というイベントを発生させるようにし、window.read(timeout=0)
を無限ループで受け付けて値が取得されたらループを抜けるという方法で対応します。
具体例としては以下です。
while True:
event, values = window.read()
if ...
#### イベント処理 ####
elif event == "hoge":
# ----------------------------------------------------------
# ポップアップ画面作成
# ----------------------------------------------------------
save_as_popup_button = sg.SaveAs("保存する", enable_events=True)
popup_layout_jp = [[sg.Text("この内容を保存しますか?", font=default_font)],
[save_as_popup_button]]
popup_window = sg.Window("Popup", popup_layout_jp)
# ----------------------------------------------------------
# ポップアップ画面 イベントループ
# ----------------------------------------------------------
while True:
popup_button, popup_values = popup_window.read(timeout=0)
if popup_button == sg.WIN_CLOSED:
break
# Save As をクリックしたときの処理
elif popup_button == "__TIMEOUT__":
if not popup_values["保存する"] in (""):
save_filepath = popup_values["保存する"]
# -------------------------
# 何らかの保存処理
# -------------------------
break
# ポップアップ画面終了処理
popup_window.close()
del popup_window
なお、この対応方法が正しいかどうかは不明です。一応問題なく動作しますが、常にイベントを発生させるのでCPUの負荷は上がります。
(自分の環境では、__TIMEOUT__
が発生するループに入った瞬間CPUの負荷が0.1% → 16%付近まで上昇しました)
同様の方法を使う場合には自己責任でお願いします。
無難なのは、「保存する」ボタン以外に「OK」などのイベント発生用ボタンを用意し、そちらで分岐させる方法です(操作の手数は多くなります)。
※ sg.popup_get_file(save_as=True)
の実現方法と同じ
【追記】上記以外の負荷が比較的かからない実現方法
sg.Button()
において、button_type=sg.BUTTON_TYPE_SAVEAS_FILE
などを指定しているとき、取得したファイルのパスはsg.Button.TKStringVar.get()
で取得できます。事前にwindow.read()
を呼び出す必要はありますが、他のイベントを取得する必要がない場合には以下のように書くことでCPU負荷を軽くできます。
while True:
event, values = window.read()
if ...
#### イベント処理 ####
elif event == "hoge":
# ----------------------------------------------------------
# ポップアップ画面作成
# ----------------------------------------------------------
save_as_popup_button = sg.SaveAs("保存する", enable_events=True)
popup_layout_jp = [[sg.Text("この内容を保存しますか?", font=default_font)],
[save_as_popup_button]]
popup_window = sg.Window("Popup", popup_layout_jp)
# ----------------------------------------------------------
# ポップアップ画面 イベントループ
# ----------------------------------------------------------
popup_button, popup_values = popup_window.read() # 変更箇所
while True:
save_filepath = save_as_popup_button.TKStringVar.get() # 変更箇所
if save_filepath != "":
# -------------------------
# 何らかの保存処理
# -------------------------
break
# ポップアップ画面終了処理
popup_window.close()
del popup_window
Menubarで「SaveAs」というイベントを発生させたときに、ファイル選択画面を表示させる方法
sg.Menu
の項目をクリックしたとき、基本的にはeventを発生させるだけで、処理を行うにはeventに応じて分岐するif節の中に処理内容を書くしかありません。
単純な処理ならいいのですが、sg.FileSaveAs
を押したときのようなポップアップを出したいときは既存のモジュールを流用したくなります。
PySimpleGUIの内部で使用されているtkinterのボタンには、click()
というメソッドが存在するので、これを呼び出せばいいことになります。
また、このclick()
メソッドを呼び出した後に、指定したファイルパスを取得するには、前述の方法と同様にwindow.read()
を呼び出せばOKです。
なお、この場合はclick()
のあとにすぐwindow.read()
でイベント取得を待ち受けることになるので、timeout=0
を指定する必要はありません。
save_as_button = sg.FileSaveAs(...) # 引数は適宜設定
### 中略 ###
event, values = window.read()
if event == "SaveAs":
save_as_button.click()
event, values = window.read()
save_filepath = values["saveas"]
### 保存パスを基にした処理 ###
### 略 ###
機能だけを使いたいボタンを、ウィンドウに表示させずlayoutに組み込む方法
ファイル選択画面の表示など、ボタンに紐づいた処理を使いたいが、ウィンドウにボタンを表示したくないという場面が存在します。
今回だとメモ帳の画面に「保存する」ボタンがあるのは変ですね。
こういうときは、sg.Button()
の引数にvisible=False
を設定すればOKです。
なお、sg.FileSaveAs
にはこの引数を設定できません。ではどうするかというと、sg.Button
の引数をカスタムしてsg.FileSaveAs
と同様の機能を持たせるということになります。
今回は以下のように設定しました。
save_as_custom_button = sg.Button(key="saveas",
button_text="Save as...",
target=(sg.ThisRow, -1),
file_types=(("ALL Files", "*.*"),("Text Files", "*.txt"),),
initial_folder=None,
default_extension="",
disabled=False,
tooltip=None,
size=(None, None),
s=(None, None),
auto_size_button=None,
button_color=None,
change_submits=False,
enable_events=True, # Trueに変更
font=None,
pad=((0,0),(0,0)),
k=None,
metadata=None,
button_type=sg.BUTTON_TYPE_SAVEAS_FILE, # sg.FileSaveAsと同様の機能を付ける
visible=False) # この設定が重要
このボタンのインスタンスをlayoutに入れたうえでsg.Window
に渡すと、ウィンドウに表示させず機能だけ使うことが可能です。
ボタンの機能を使いたい場合、上記の例ではsave_as_custom_button.click()
のように、click()
メソッドを呼び出すと、マウスでクリックした場合と同様の処理を行うことができます。
これはどのボタンでも共通なので、ファイルを開きたい場合にはbutton_type=sg.BUTTON_TYPE_BROWSE_FILES
を指定することになります。
※ sg.Button()でbutton_type
を指定したとき、内部的にはtk.filedialog.askopenfilenames()
やtk.filedialog.asksaveasfilename()
などの関数を呼び出しています。
フォント情報一覧の取得
以下でリストを取得できます。tkinterのモジュールから取得しています。
import tkinter as tk
font_list = [font for font in tk.font.families() if not font[0] == "@"]
"""
【tk.font.families()の出力】
('System', '@System', 'Terminal', '@Terminal', ...)
"""
そのままだと”@”が先頭に入った文字列も併せて取得されてしまうので、それは除外するように書いています。
なお、tk.font.families()は、root = tk.Tk()
が作成されていないときにはエラーとなるのでご注意ください。
※ PySimpleGUIでは、Window()
が作成されていれば問題ないと思います。
undo / redo の実装
undo機能の設定
sg.Multiline
に対応するtkinterのWidget tk.scrolledtext.ScrolledText
のconfigure()
メソッドにて、引数にundo=True
を設定すればOKです。こうすることで、「Ctrl + Z」によるundoも有効化されます。
なお、序盤でも述べたようにsg.Multiline
に対応するtkinterのWidgetがsg.Window
クラス内で実際に生成されるのはWindow.read()
が呼び出されるか、sg.Window
のコンストラクタでfinalize=True
を指定した場合のみです。
今回は、sg.Window(..., finalize=True)
としたのち、ループ処理に入る前にconfigure(undo=True)
とすることで対応しました。
具体的には、以下のように書きます。
import PySimpleGUI as sg
layout = [[sg.Multiline(key="multiline")]]
window = sg.Window("title", layout, finalize=True) # finalize=Trueが必要
multiline = window["multiline"].Widget # この2行がポイント
multiline.configure(undo=True)
while True:
event, values = window.read()
### 以下ループ処理 ###
redo機能の実装
redoについては、「Ctrl + Y」でも実行できるようにkeyと関数をtk.scrolledtext.ScrolledText.bind()
メソッドで設定します。
これについてはネット上の知見を流用した形になります。やり方は以下です。
import PySimpleGUI as sg
# redo機能を実現する関数
def redo(event, text):
"""
text: tk.scrolledtext.ScrolledTextのインスタンス。他のText入力ウィジェットでも同様か
"""
try:
text.edit_redo()
except:
pass
layout = [[sg.Multiline(key="multiline")]]
window = sg.Window("title", layout, finalize=True) # finalize=Trueが必要
multiline = window["multiline"].Widget # この2行がポイント
multiline.bind("<Control-Key-Y>", lambda event, text=text:redo(event, text)) # redo関数を「Ctrl + Y」にbind
while True:
event, values = window.read()
### 以下ループ処理 ###
Windowを”X”ボタンで閉じるときに、確認のポップアップを表示させる方法
デフォルトでは、”X”ボタンを押したときにイベントが発生せず、そのままウィンドウが閉じてしまいます。
閉じる前に確認のポップアップを表示したい際には困る仕様ですが、これも簡単な方法で対応可能です。
具体的には、sg.Window
のコンストラクタにenable_close_attempted_event=True
を指定します。
これで、”X”ボタンを押したときに、-WINDOW CLOSE ATTEMPTED-
イベントが発生するので、それに応じた分岐処理を作成すればOKです。
具体的には、以下のように実装します。
import PySimpleGUI as sg
sg.theme("SystemDefault1")
layout = [[sg.Multiline(key="multiline", pad=((0,0),(0,0)))]]
window = sg.Window("title", layout, margins=(0,0), resizable=True, finalize=True, enable_close_attempted_event=True)
window["multiline"].expand(expand_x=True, expand_y=True) # ウィンドウサイズに応じて入力エリアを可変に
while True:
event, values = window.read()
if event == sg.WIN_CLOSED:
break
if event == "-WINDOW CLOSE ATTEMPTED-":
sg.popup_ok("終了します", font=("MeiryoUI", 10))
break
window.close()
このコードを実行したとき、右上の”X”ボタンを押すと以下のようになります。OKをクリックするとプログラムが終了します。
クリップボードへのコピー・内容取得・削除の方法
これはtkinterのWidgetの機能を使用します。sg.Multiline
の話なので、対応するモジュールはtk.scrolledtext.ScrolledText
になります。
使用するメソッドは以下の3種類です。
tk.scrolledtext.ScrolledText.clipboard_append()
tk.scrolledtext.ScrolledText.clipboard_get()
tk.scrolledtext.ScrolledText.clipboard_clear()
コードの例では、以下のようになります。
import PySimpleGUI as sg
sg.theme("SystemDefault1")
layout = [[sg.Multiline(key="multiline", pad=((0,0),(0,0)))],
[sg.Button("OK")]]
window = sg.Window("title", layout, margins=(0,0), resizable=True, finalize=True)
window["multiline"].expand(expand_x=True, expand_y=True) # ウィンドウサイズに応じて入力エリアを可変に
while True:
event, values = window.read()
if event == sg.WIN_CLOSED:
break
if event == "OK":
# clipboard の内容取得(事前に操作しているとその内容が反映される)
text = window["multiline"].Widget.clipboard_get()
print(text)
# clipboard に "test sentence."という内容を登録
window["multiline"].Widget.clipboard_append("test sentence.")
text = window["multiline"].Widget.clipboard_get()
print(text)
# clipboard の中身を消去
window["multiline"].Widget.clipboard_clear()
# Error (clipboardの中身を消去しているので、中身が空になっている)
text = window["multiline"].Widget.clipboard_get()
print(text)
window.close()
上記のコードでは、OKボタンを押すとクリップボードへの操作を実行するようにしています。
初めにクリップボードの中身をコンソールに出力、次に”test sentence.”という内容をクリップボードにコピー、その中身をコンソールに出力、クリップボードの消去、クリップボードの中身をコンソールに出力(中身が空なのでエラー)というものです。
最後にエラーが起きますが、実際にクリップボードへアクセスする処理を書く場合は、try - except
構文を用いて例外処理にし、GUIアプリ自体が停止しないようにします。以下のようなイメージです。
try:
text = window["multiline"].Widget.clipboard_get()
print(text)
except:
pass
sg.Multilineの文章で選択している範囲を取得し、その部分だけ削除する方法
主に「切り取り」機能を実装するために必要な内容になります。
工程としては以下のように切り分けられます。
- 選択範囲のテキストをプログラム内の変数に格納
- 選択範囲の最初と最後の位置を取得し、それを削除
いずれもtkinterのモジュールの機能を使います。前者はtk.scrolledtext.ScrolledText.selection_get()
、後者はtk.scrolledtext.ScrolledText.delete
、選択範囲の取得にtk.SEL_FIRST
、tk.SEL_LAST
を使用します。
コードの具体例は以下の通りです。
import PySimpleGUI as sg
import tkinter as tk
sg.theme("SystemDefault1")
layout = [[sg.Multiline(key="multiline", pad=((0,0),(0,0)))],
[sg.Button("OK")]]
window = sg.Window("title", layout, margins=(0,0), resizable=True, finalize=True)
window["multiline"].expand(expand_x=True, expand_y=True) # ウィンドウサイズに応じて入力エリアを可変に
while True:
event, values = window.read()
print(event)
if event == sg.WIN_CLOSED:
break
if event == "OK":
try:
selected_text = window["multiline"].Widget.selection_get()
window["multiline"].Widget.delete(tk.SEL_FIRST, tk.SEL_LAST) # 選択範囲の最初から最後までを削除
print(selected_text)
except:
pass
window.close()
これで、適当に文字を入力し、選択した状態でOKを押すとその部分を削除、選択範囲はプログラム内に取得されるのでコンソールへ出力という挙動を確認することができます。
sg.Multilineの文章中のどこにカーソルがあるかを表示する方法
カーソル位置を取得できれば表示ができます。今回作成したアプリでは、左下に表示している内容をどう実装するかという話になります。
これについては、例によってtkinterのWidgetのメソッドを使用することで実現できます。
具体的には、tk.scrolledtext.ScrolledText.index("insert")
を使用します。index()
メソッドに、”insert”という引数を与えることでカーソル位置が取得できます。なお、返り値は”(縦の位置).(横の位置)”という文字列で返されるため、pythonのstr型のメソッドsplit()
を使って分離してやる必要があります。
サンプルコードは以下の通りです。
import PySimpleGUI as sg
import tkinter as tk
sg.theme("SystemDefault1")
layout = [[sg.Multiline(key="multiline", pad=((0,0),(0,0)))],
[sg.Button("OK")]]
window = sg.Window("title", layout, margins=(0,0), resizable=True, finalize=True)
window["multiline"].expand(expand_x=True, expand_y=True) # ウィンドウサイズに応じて入力エリアを可変に
while True:
event, values = window.read()
print(event)
if event == sg.WIN_CLOSED:
break
if event == "OK":
insert_pos = window["multiline"].Widget.index("insert")
insert_pos = insert_pos.split(".")
print("行:{} 列:{}".format(insert_pos[0], int(insert_pos[1])+1))
window.close()
このように書くと、OKボタンを押す度にカーソルのある位置がコンソールに出力されます。
GUIの画面上に表示したい場合は、表示したいウィジェットをwindowのキーで指定し、Update()
メソッドを呼び出せばOKです。
まとめ
今回はPySimpleGUIでメモ帳を作る際にハマったポイントと解決策についてまとめてきました。
次回、Pythonスクリプトの実行ファイル化とアイコン設定についてまとめる予定です。
コメント