LoginSignup
25
39

More than 1 year has passed since last update.

Pythonで簡単にGUIを作ろう!【PySimpleGUIケーススタディ】

Last updated at Posted at 2021-12-20

0. はじめに

今年Pythonの記事を結構書いてきましたが、最近の記事かつまぁまぁ閲覧数が多かったのがGUIの記事【PySimpleGUI】PythonでオリジナルGUIアプリを作成 だった為に需要はそこそこあると判断し、本記事ではPySimpleGUIのケース集を共有していこうと思います。

また、前座として簡単ですがGUIライブラリの比較もしてみたのでおまけ程度に楽しんでいっていただければと思います。

  • 動作環境
    • OS : Windows10 pro
    • Python : 3.8.3
    • PySimpleGUI :4.55.1
    • jupyter notebook
    • (Kivy : 2.0.0)
    • (PyQt5 : 5.15.6)
    • (Tkinter : 8.6.9)

1. PythonGUIライブラリ簡易比較

かっこいい本格的なGUIを作りたいという場合はC++やC#,JAVA等が候補になるのかもですが、近年は機械学習と簡単に組み合わせやすいPythonの選択肢が増えてきているそうです。

そこでどんなライブラリが人気なのか?を調べました。
参考サイト:What are the best Python GUI frameworks/toolkits?

せっかくのアドベントカレンダーの記事なので以下ではランキング上位のライブラリで同じようなGUIを作るのにどんなコード書くの?を比較し、それぞれ簡単ですが記述してみました。気になる場所だけ折りたたみを開いて中身見ていってください。
※PySimpleGUIのケーススタディだけ見たいという方は 2. PySimpleGUIのケーススタディ に飛んでください。

1-1. Kivy(ランキング1位)

★kivyに関してとサンプルコード
  • Kivyに関して
    • OS : Android、iOS、Linux、MacOS、Windows
    • ライセンス : MIT
    • 特徴/デメリット : 見た目がモダン、スマホアプリ対応、Kivy言語を習得する必要ありOpenGL対応
    • 導入:pip install kivy
    • ドキュメント(日本語) : https://pyky.github.io/kivy-doc-ja/

実は今回記事を書くのに初めてKivyを触りましたが、スマホ系のGUIならこれ使うんでしょうかね?
複雑なことをするのにKivy言語を書く(以下サンプルには未使用)のでそれがネックになりそうです。
※海外ランキングだとこれが1位と書いてありますが、私は正直このライブラリの話をあまり聞いたことありませんでした。

hello_world
'''jupyterで実行すると全画面を奪われるので注意'''

from kivy.app import App
from kivy.uix.label import Label

class MyApp(App):
    def build(self):
        return Label(text="Hello world")

MyApp().run()

1-2. PyQt(ランキング2位)

★PyQtのサンプルコード
  • PyQtに関して
    • C++言語で記述された「Qt」をPythonで使えるようにしたもの (※PySideというのも存在するが本記事では省略)
    • OS : Android、iOS、Linux、MacOS、Windows (※PyQt4以下はios等未対応)
    • ライセンス : GPL
    • 特徴/デメリット : 見た目がモダン、スマホアプリ対応、営利目的での利用に制限あり
    • 導入:pip install PyQt5 ※PyQt6がすでにあるみたいだが、本記事はPyQt5で実施しました
    • ドキュメント : https://github.com/pyqt/examples

PyQtは正直使いやすいと思います、がGPLライセンスが最大のデメリットになります

hello_world
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel

def window():
    app = QApplication(sys.argv)
    widget = QWidget()

    textLabel = QLabel(widget)
    textLabel.setText("Hello World!")
    textLabel.move(170,85) #Window内文字の配置座標(左上)

    widget.setGeometry(50,50,500,200) #Windowの初期表示位置とサイズ(省略可能)
    widget.setWindowTitle("PyQt5")
    widget.show()
    sys.exit(app.exec_())

window()

1-3. Tkinter(ランキング3位)

★Tkinterのサンプルコード
  • Tkinterに関して
    • OS : Linux、MacOSX、Windows
    • ライセンス : PSF (※Python本体と同じ扱い)
    • 特徴/デメリット : Python標準、日本語記事が一番多い、書き方も多いが書く量も多い
    • 導入:なし(pythonプリインストール)
    • ドキュメント(日本語) : https://docs.python.org/ja/3/library/tkinter.html

Python標準なことが本ライブラリの最大の特徴。書籍とかにも情報はあるし、多分ググれば情報も一番多いと思う。
ただ書く量は多くなる。※パターンによって自分で色々書けるのは当然強みでもある

hello_world
import tkinter as tk 

# ウィンドウ
window = tk.Tk() 
window.title("tkinter") #タイトル
window.geometry("300x200") # Windowサイズ

#ラベル
txt = tk.Label(text='Hello World')
txt.place(x = 100, y = 70, width = 100, height = 50) #絶対座標配置(パターンも記述も可)

window.mainloop() # mainloop呼び出し

1-4. PySimpleGUI(まさかのランキング圏外・・)

★PySimpleGUIのサンプルコード

海外サイトでは全く人気なさそうですね。。日本ではこれを使用した記事が最近目立ちだした気がしますが..

本記事はライセンスの記事ではないので詳細は省略しますが、動的リンクを活用すれば商用でも利用可能です。
シンプルな書きやすさなら個人的にはダントツでこれだと思ってます。
※このサンプルだけ見ると記述量が一番多いと感じるかもですが、部品数が増えると逆に記述量減ります

hello_world
import PySimpleGUI as sg

# レイアウト
layout = [
    [sg.Text(text='Hello World!', pad=(100,100))]
]

# window 
window = sg.Window('PySimpleGUI', layout) #文字をど真ん中に配置させるためにサイズは「あえて」指定しない

# イベントのループ
while True:
    # イベント取得
    event, values = window.read()
    # ウィンドウの×ボタンが押された場合break
    if event == sg.WIN_CLOSED:
        break

# ウィンドウ終了処理
window.close()

2. PySimpleGUIのケーススタディ

さてここからが本記事の本題です。

PySimpleGUIは、難しいルールはあまり無く基本的には
レイアウトの中に上から[]単位で部品を縦並びに配置
同じ[]の中にカンマ区切りで部品を書けば横並びに配置
どんな部品(テキスト/画像/表/・・etc)を配置するか?
くらいで設置座標とかすら基本的には気にすることはないんですが、そうは言ってもこういうケースの場合どう書けばいいの?と悩まないわけでもないです。

通常は公式のCall referenceや、公式のCookbookGitHubのIssuesStack Overflow等で探して解決すると思いますが、自分が悩んで調べたことがあるケースはきっと他人も悩むだろうという仮定の元で、今後小規模なGUIアプリを作ろうと考えている人向けに今回は私が過去に調べたことがあるケースを6例書いていきます。

まずは下記コードのコピペでいいので動かしてもらい、動作を理解してからコード部分で参考になりそうな箇所だけ各自の設計GUIに取り込んでもらえればと思います。

以下は折りたたみ表現を使用してますので、気になるケースだけ中身を開いて見ていってください。

2-1.部品を自動配置ではなく特定の場所に配置したい

★部品を特定の場所に配置したい

私が公式のリファレンスを読んだ限りでは、座標指定での配置は無理っぽいです。何も考えなければ左上から順番に配置されてしまいます(それこそがPySimpleGUIのいい部分でもありますが・・)

今回のケースでは各要素にpaddingパラメータを使うのがいいと思います。今回はフレームを使用してイメージが湧くように作成しました。
このボタンやテキストはこの位置にどうしても配置したい!!という拘り要望がある人はpaddingをうまく使ってみてはいかがでしょうか?

bindを使用した例
import PySimpleGUI as sg

#上下方向にpadding
frame1 = sg.Frame('フレーム1',[
    [sg.Text(text = 'Hello World!',pad = (None,30))] #上下のみに30ピクセル設定
])
#左右方向にpadding                  
frame2 = sg.Frame('フレーム2',[
    [sg.Text(text = 'Hello World!',pad = (30,None))] #左右のみに30ピクセル設定
])
#上下左右にpadding
frame3 = sg.Frame('フレーム3',[
    [sg.Text(text = 'Hello World!',pad = (30,30))] #左右上下に30ピクセル設定
])
#上下左右全部個別ににpadding
frame4 = sg.Frame('フレーム4',[
    [sg.Text(text = 'Hello World!',pad = ((100,30),(30,100)))] #(左,右)、(上,下)個別に設定(100or30ピクセル)
])

# レイアウトとしてフレームを縦に並べて表示
layout = [
        [frame1],
        [frame2],
        [frame3],
        [frame4],
]

# window
window = sg.Window('padding確認', layout)

# イベントのループ
while True:
    # イベントの読み込み
    event, values = window.read()
    # ウィンドウの×ボタンが押されれば終了
    if event == sg.WIN_CLOSED:
        break

# ウィンドウ終了処理
window.close()

2-2.レイアウトを縦横につなげたい

★レイアウトをつなげる

レイアウト合体はsg.Columnsg.Frameの好きな方を使えばいいと思います。

別レイアウトの合体
import PySimpleGUI as sg

column1 = sg.Column(
    [
        [sg.Text('column1です')],
    ],size =(100,100)
)

column2 = sg.Column(
    [
        [sg.Text('column2です')],
    ],size =(100,200)
)

frame1 = sg.Frame('',
    [
        [sg.Text('frame1です')]
    ],size =(100,100)
)

frame2 = sg.Frame('2行目',
    [
        [sg.Text('frame2です')],
    ],size =(300,100)
)

#全体レイアウト
layout = [
    #1行目
    [
        column1,
        frame1,
        column2,
    ],
    #2行目
    [
        frame2
    ]
]

#Wiondow
window = sg.Window('レイアウト配置サンプル', layout, resizable=True)

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

    if event is None:
        break

window.close()

2-3.キーボード(とボタン)をイベント連携させたい

★キーボード(とボタン)をイベント連動

例えば「OKボタン」と「Enterキー」を連動させたい!とかのケースです。※ボタンは無くてもキーボードだけイベントでもOK
私が知ってるやり方は2通りありますので紹介します。

2-2-1.return_keyboard_eventsを使用して検出する方法

Windowの設定でキーボードイベントを受け付けるか否かを設定できる。
Enterキーは「\r」だが、この割り当てはevent, values= window.read() ⇒ print(event)でいちいち調べればOK

return_keyboard_eventsを使用した例
import PySimpleGUI as sg

#レイアウト
layout= [
    [sg.InputText('表示させるテキストを入力してください',key='Input1'),
     sg.Button(button_text="入力", key='button1')],
    [sg.Multiline(key='OUTPUT', expand_x=True, expand_y=True)], #expandで表示エリアをWindowに追従させる
]

#window
"""return_keybord_eventsでキーボード入力をイベントに設定する"""
window= sg.Window('bindの練習GUI', layout, finalize=True, resizable=True, return_keyboard_events=True)

while True:
    event, values= window.read()
    #print(event) #どのキーがどんなイベントになるかを確認するデバッグ用

    if event== sg.WINDOW_CLOSED:
        break

    elif event == "\r" or event == "button1": #イベント(Enterキー or 入力ボタンを押す)
        window['OUTPUT'].print(values['Input1']) #表示エリアにprintさせる
        window['Input1'].update('') #次回の入力に備えて入力領域をクリアする

window.close()

2-2-2.bind機能を使用する方法

bindはそもそもTkinterの機能ですが、ラッパーライブラリのpysimpleGUIでも使用できます。そしてbindは書く部品のkeyに結び付けるか、直接Windowに結び付けることが出来ます。
bindとは特定の入力と特定の動作を結び付けることを指す

bindを使用した例
import PySimpleGUI as sg

#レイアウト
layout= [
    [sg.InputText('表示させるテキストを入力してください',key='Input1'),
     sg.Button(button_text="入力", key='button1')],
    [sg.Multiline(key='OUTPUT', expand_x=True, expand_y=True)], #expandで表示エリアをWindowに追従させる
]

#window
window= sg.Window('bindの練習GUI', layout, finalize=True, resizable=True) #resizableでGUIサイズを可変にしておく

#windowへbindを設定
"""表示Windowが最前面でアクティブな時「CtrlとEnterを同時押し」すると「Enter1」というイベントを起こす"""
window.bind('<Control-Key-Return>', 'Enter1') #Enter2イベントと衝突しないようにあえてCtrlキーを混ぜてます

#部品へbindを設定 ※部品の場合はkeyに対してさらに機能をのせる感じに書く
"""テキスト入力エリアがアクティブな時に「Enterを押す」と「Enter2」というイベントを起こす"""
window['Input1'].bind("<Key-Return>", "Enter2")

while True:
    event, values= window.read()
    if event== sg.WINDOW_CLOSED:
        break

    elif event == "Enter1" or event == "button1": #Enter1イベント(Enterキー or 入力ボタンを押す)
        #"Enter1"のイベント時は黒でテキスト表示
        window['OUTPUT'].print(values['Input1'], colors='#000000') #表示エリアにprintさせる
        window['Input1'].update('') #入力領域をクリア

    elif event == "Input1" + "Enter2": #部品にバインドさせた場合は「+」で元イベントと連結させる必要がある
        #"Enter2"のイベント時は赤でテキスト表示
        window['OUTPUT'].print(values['Input1'], colors='#ff0000')
        window['Input1'].update('') #入力領域をクリア

window.close()

bindの一覧はTkinterの公式解説に一覧が無く、多分ここ?が一応公式らしいが、中身微妙なので私はここ※bindとeventについてを参考にしました。

2-4.複数Windowの状態を検知したい

★複数Window状態検知

ボタン押したらサブのWindow出すパターンの簡易例です。ボタンアクションでサブWindowを非表示⇒表示状態に変化させてます。※でないとボタン押すたびに無限にサブWindowが出現してしまうので・・

マルチWindowの状態取得
import PySimpleGUI as sg

#ボタンのみの簡単レイアウト
layout= [[sg.Button(button_text="サブ画面起動", key='button1', size=(20,20))]]

#window1(メインWindow)
window_main= sg.Window('メインWindow', layout, finalize=True, resizable=True)

#window2(サブWindow) ※文字のみの簡単レイアウト
sub_layout = [[sg.Text(text = 'サブ画面だよ~', font=('メイリオ', 20))]]

window_sub = sg.Window('サブWindow', sub_layout, finalize=True, resizable=True)
window_sub.hide() #サブ画面を表示させない※ボタンを押すまで非表示

while True:
    """
    ・全アクティブWindow状態を「read_all_windows」で検出
    ・read_all_windowsの「window」はイベントが発生した自分が定義済のWindowが出力される
    ・eventとvaluesは今までと使い方変わらない。
    """
    window, event, values = sg.read_all_windows()

    if event == sg.WIN_CLOSED: #アクティブなWindowのうちどれかでバツが押された場合
        if window == window_main: #対象がメインWindowの場合
            window_main.close() #メインWindowを閉じる
            window_sub.close() #サブWindowも一緒に閉じる
            break
        elif window == window_sub: #対象がサブWindowの場合
            window_sub.hide() #非表示に戻す

    if event == 'button1': #別Window起動ボタンが押されたらサブ画面起動
    #if (window == window_main) and (event == 'button1'): #これでもOK
        window_sub.un_hide() #サブ画面を表示させる

画像だけではわからないかもだが、実行すればきちんとサブ画面のクローズを検知できるし、メイン画面との区別もできていることがわかる。

2-5.ソートした表テーブルを即反映させたい

★表のソートを反映

csvやDBか何かをテーブルで読み込んで、ソートした時にそれに追従させる。今回はsklearnの「ボストン住宅価格データセット」をサンプルとして使用した。

表ソートの状態反映
"""
まずはBoston住宅価格データセットの準備
今回はボストンデータセットのカラムから「LSTAT(給料の低い職業の人口割合)」をソートするサンプル
"""
from sklearn.datasets import load_boston
import pandas as pd

#ボストンのデータセットを読み込む
boston = load_boston()
dataset = pd.DataFrame(data = boston['data'], columns = boston['feature_names'])
dataset_origin = dataset.copy() #オリジナルをコピーで保持しておく

"""以下はPySimpleGUI部分"""
import PySimpleGUI as sg

layout= [
    #表テーブル部分
    [sg.Table(
        values = dataset.values.tolist(), #表の中身
        headings = dataset.columns.tolist(), #ヘッダー
        justification = 'left', #表の中身を左寄せ
        auto_size_columns=False, #これFalseにすると(col_widths)が有効になる
        col_widths = [5,5,6,5,5,5,5,5,5,5,8,5,5], #カラムサイズは個別設定可能(オススメ)
        vertical_scroll_only=False, #これFalseにしないと表を水平方向にスクロールできない
        key='boston')
    ],
    [
        sg.InputText('数字を入力してください', key='LSTAT', enable_events=True),
        sg.Button(button_text="ソート起動", key='button1'),
    ],
    [
        [sg.Button(button_text="ソートリセット", key='button2')],
    ]
]

window= sg.Window('テーブルの練習GUI', layout, finalize=True, resizable=True)

#Windowに追従してテーブルを拡大させる設定 ※sg.Table側で設定しないのがキーポイント
window['boston'].expand(expand_x=True, expand_y=True)

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

    if event== sg.WINDOW_CLOSED:
        break
    elif event == "button1": #"button1"イベントでソートをかける
        try: #入力が小数(+整数)かどうか?の判断をPythonで簡単にはできない為今回はtry文を使用した
            if float(values['LSTAT']): #入力数字が小数として読み取れる場合
                dataset = dataset[dataset['LSTAT'] <= float(values['LSTAT'])]
                window['boston'].update(values = dataset.values.tolist()) #ソート済の表に更新
                #window['LSTAT'].update('') #入力エリアをクリアしてもOK
            else: #読み取れない場合
                sg.popup('数字を入れてください') #警告ポップアップ
                window['LSTAT'].update('')
        except:
            sg.popup('数字を入れてください')
            window['LSTAT'].update('')
    elif event == "button2": #"button2"イベントでは表をリセットさせる
        dataset = dataset_origin.copy() #保存しておいたオリジナルを使用
        window['boston'].update(values = dataset_origin.values.tolist()) #オリジナルで更新

window.close()

これも画像だけではわからないが、ソート実行ですぐにテーブルが更新される。

2-6.GUIへ表示させた画像をマウスドラッグし、ドラッグ選択部分を保存

★マウスドラッグの使い方

今回はWindowsで言う「Win+Shift+s」(スクショ+画像切り取り)と同じようなことを再現してみた。
※マウスをクリックしたまま範囲を選択(マウスドラッグ)して何かしたい時の参考例。
※スクショじゃなく、pillow等で自身の画像を読み込んでも同じように作成可能。
※GUIの画面サイズはスクショ原本の半分にしている(GUIの使いやすさを重視)

スクショ+領域選択後の画像保存
import PySimpleGUI as sg
import pyautogui
from PIL import Image
import io
from datetime import datetime
from win32api import GetSystemMetrics

"""
まずはPyAutoGUIでスクショを取得する部分。
まともにやると後ほどGUIサイズが作業画面いっぱいになってしまうので、工夫した方が使いやすい。
なお、画面サイズはPyAutoGUIでも取得可能
"""
gui_width = int(GetSystemMetrics(0)/2) #作業モニタの幅の半分のサイズ取得
gui_height = int(GetSystemMetrics(1)/2) #作業モニタの高さの半分のサイズ取得

#pyautoguiでスクリーンショットを取得する
img = pyautogui.screenshot()
#解像度の半分にリサイズをする ※原寸サイズだと大きすぎるので
img_resize = img.resize((gui_width, gui_height), Image.LANCZOS) #LANCZOSは精度が一番いい

"""
以下はPySimpleGUI部分
"""
#Graphオブジェクトのdraw_image用に画像をbyte列に変換する
img_bytes = io.BytesIO()
img_resize.save(img_bytes, format='PNG')
img_bytes = img_bytes.getvalue() #これがbyte変換画像

layout = [
    #グラフ部品。他のライブラリでいうCanvasという部品に近い?
    [sg.Graph(
        canvas_size=(gui_width, gui_height), #Canvasサイズ ※画像サイズに合わせた
        graph_bottom_left=(0, gui_height), #左下座標 (左下をx=0、y=max高さにした)
        graph_top_right=(gui_width, 0), #右上座標 (右上をx=max幅、y=0にした)
        key="-GRAPH-",
        enable_events=True, #マウスクリックでイベントを発生させる
        background_color='lightblue',
        drag_submits=True)], #マウスボタンをクリックしたままの状態でもイベントを発生させる
    [sg.Text(key='info', size=(60, 1), font=('メイリオ',12))] #ドラック範囲を表示させる
]

window = sg.Window("スクショドラッグ練習用Winodow", layout, finalize=True)

#draw_imageでグラフオブジェクトにbyte化したスクリーンショット画像を描画する
window["-GRAPH-"].draw_image(data=img_bytes, location=(0,0)) #locationはグラフオブジェクト左上の始点座標

dragging = False #クリック継続フラグ
start_point = end_point = prior_rect = None #矩形情報の初期化

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

    if event == sg.WIN_CLOSED:
        break

    if event == "-GRAPH-":  #「マウスがクリックされた」or「クリックされ続ける間」イベント発生
        x, y = values["-GRAPH-"] #マウスの現在座標を取得
        if not dragging: #マウスをクリックした瞬間(クリック継続フラグがFalse)
            start_point = (x, y) #始点座標取得
            dragging = True #クリック継続フラグを切り替え(始点はもう取得したので)
        else: #マウスをクリックし続けている間
            end_point = (x, y) #「現時点」での終点座標を取得
        if prior_rect: #すでに矩形の赤枠が表示されていた場合にはdelete_figureで矩形を消去(2回目以降の作業用)
             window["-GRAPH-"].delete_figure(prior_rect)
        if None not in (start_point, end_point): #始点と終点が存在する場合はdraw_rectangleで赤の矩形を描画
            prior_rect =  window["-GRAPH-"].draw_rectangle(start_point, end_point, line_width=5, line_color='red')

    elif event.endswith('+UP') and (None not in (start_point, end_point)): # 座標取得+マウスを離した瞬間(+UPイベント)
        #ドラッグで選択した座標情報をテキストエリアにアップデート
        window["info"].update(value=f"grabbed rectangle from {start_point} to {end_point}")
        #以下でドラッグ派にの画像を切り抜いて保存処理
        if start_point!=end_point: #始点と終点が同じ座標ではない場合のみ処理(画像として切り取れない)
            #最小座標を取得 ※ドラッグのやり方次第で始点>終点のケースが存在する為
            min_x = min([start_point[0],end_point[0]])
            min_y = min([start_point[1],end_point[1]])
            max_x = max([start_point[0],end_point[0]])
            max_y = max([start_point[1],end_point[1]])
            #「原本画像※リサイズ前」を切り取り(crop)する ※left, upper, right, lower
            img_crop = img.copy().crop((min_x*2, min_y*2, max_x*2, max_y*2)) #ドラッグ座標は2倍にすれば原本と同じになる
            #outputフォルダに保存させる ※名前に現在時刻を組み込んで連続保存可能にした
            img_crop.save("./output/screen_shot" + str(datetime.now().strftime("%Y.%m.%d.%H.%M.%S.%f")) + '.png')
            #ポップアップで保存完了を知らせる。※any_key_closesでOKボタン押さずともEnter等で消せるようにした
            sg.popup('切り抜き完了', any_key_closes=True)
            #次回に備えて座標とフラグをリセットする
            start_point, end_point = None, None
            dragging = False
        else: #同じ座標だと切り抜けないので警告してやり直させる
            sg.popup('画像を切り取りできません。やり直してください')
            #座標とフラグをリセットする
            start_point, end_point = None, None
            dragging = False

window.close()

3. おわりに

本記事では前座としてGUIライブラリの簡易比較、本題としてPySimpleGUIのケース集を記載してきた。
日本語記事 & こういうケースでどう記載すればいいの?という1つのサンプルとして参考にしていただけると幸いです。

それでは本記事はここまで。

25
39
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
39