【備忘録】PySimpleGUIでメモ帳を作ってみる【ハマりどころ解消Tips編】

IT関連

最近、Pythonスクリプト組んだり、プライベートでも勉強がてらDeep LearningをC++で実装してみたりと、CLIベースで操作するコードはよく書くものの、そういえばGUIで何かタスクをやらせるというものは作ったことがないなと思い至りました。

折角なので、イベント駆動のプログラミングの勉強を兼ねてGUIアプリを何か作ろうと思い、とりあえずPythonでメモ帳的なものを作ってみました。

その際、PySimpleGUIというライブラリを使って作ったのですが、見た目とかちょっとした機能にこだわるとバックエンドのライブラリであるtkinterの操作を要求されたりと、結構ハマりどころがあったので備忘録がてらまとめます。

また、Pythonので作成したコードから Pyinstaller というモジュールを使用するとexe化ができるなど、ターミナル操作に慣れていない人でも使えるようにパッケージングするところまでやってみたので、そこのノウハウもまとめようと思います。思いのほかTipsだけで記述量が増えてしまったので、アイコンの設定・exe化のノウハウについては後日別の記事にまとめます。

 …Pythonのスクリプトをexe化したら簡単なプログラムでも10MB行くとか言っちゃダメです 

PySimpleGUIとは?

PySimpleGUIとは、pythonでGUIを作るためのライブラリです。
pythonでGUIを作るためのライブラリは他にtkinterやPyQtなどが存在するのですが、それらをラッピングしてより少ない記述量でプログラムを書けるようにしたものがPySimpleGUIです。

GitHub - PySimpleGUI/PySimpleGUI: Launched in 2018. It's 2023 and PySimpleGUI is actively developed & supported. Create complex windows simply. Supports tkinter, Qt, WxPython, Remi (in browser). Create GUI applications trivially with a full set of widgets. Multi-Window applications are also simple. 3.4 to 3.11 supported. 325+ Demo programs & Cookbook for rapid start. Extensive docs
Launched in 2018. It's 2023 and PySimpleGUI is actively developed & supported. Create complex windows simply. Supports tkinter, Qt, WxPython, Remi (in b...
PySimpleGUI
None

とりあえず動くGUIアプリケーションを作る、という意味では使いやすくてオススメのライブラリです。
なお、使いやすいということは融通が利きにくいということで、細かい部分をチューニングするにはバックエンドで動作しているライブラリの使い方にまで踏み込む必要があります。

今回はメモ帳を作るにあたって一部tkinterの知識を要求されたので、それについても書いていきます。

ちなみにPySimpleGUIのライセンスはLGPL 3.0です。
作成したスクリプトのexe化まで紹介しますが、これを再頒布するときはソースコードの公開が必要になります。

これを基に良いアプリを作っても、商用でバイナリだけ配布するという真似はできないわけですね。残念。

今回作ったもの

こんな感じの見た目のアプリです。

機能としては

  • テキストの編集・保存
  • 右クリックで Redo / Undo / Copy / Paste / Cut の操作が行える
  • カーソル位置のステータスバー上への表示
  • フォントの変更機能
  • メモ帳を閉じる際の確認画面表示

といったところになります。

ソースコードはgithubに公開していますので、全体像を見たい方はそちらを参照ください。
コード自体は350行程度の小規模なものです。

GitHub - potedo/PySimpleNotePad: Notepad App made by PySimpleGUI
Notepad App made by PySimpleGUI. Contribute to potedo/PySimpleNotePad development by creating an account on GitHub.

ハマったポイントと解決策

以下、メモ帳の機能を実装するにあたり結構ハマったポイントとその解決策についてまとめていきます。
なお、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.ScrolledTextconfigure()メソッドにて、引数に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_FIRSTtk.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スクリプトの実行ファイル化とアイコン設定についてまとめる予定です。

コメント

タイトルとURLをコピーしました