10
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

PySimpleGUIでスケジュール管理プログラムの作成 その2

Last updated at Posted at 2022-02-19

はじめに

PySimpleGUIの勉強を兼ねて、スケジュール管理プログラムを作成しました。
作成にあたり、多くの方の記事を読んで勉強させて頂きました。
同じような機能をもったGUIを作成したい方の参考になるかもしれないと思い、解説記事を作成しました。
間違いやもっと良い書き方などあれば、ご指摘頂けますと幸いです。

作成したプログラムの機能はこちらで説明しております。

PySimpleGUIの基本的な使い方

PySimpleGUIでGUIを作成するプログラムは、次の順で作成していきます。

  1. Layoutの作成
    インプット欄やボタンなどの要素をsg.Input(~), sg.Button(~)という形式で作成します。それらの要素を2次元配列のような形でリストに格納してLayoutを作成します。リストは以下のように、1行目に表示する要素を最初のリストに、2行目に表示する要素と2つ目のリストに入れて作成します。
layout = [# 1行目に表示する要素のリスト
          [sg.Input(), sg.Text("+"), sg.Input(), sg.Text("=   ")],
          # 2行目に表示する要素のリスト
          [sg.Button("計算実行")]
]
これにsg.Tabやsg.Columnなどのlayoutを内側に格納できる要素を使うことでより複雑なGUIにすることができます。詳しくは後述します。   

各要素を作成する時に、初期値や大きさなどの設定を引数として渡して、細かな設定ができます。設定の詳しい内容はこちらのPySimpleGUI Call referenceで調べることができます。
引数は要素タイプによって異なりますが、どの要素でもよく使う5つをここで紹介しておきます。

sg.Input("初期値", size=(10,5), pad=0, key="-r1_inp_00-", enable_events=True)
引数 説明 補足
default 初期値 要素によって引数名が違うのでCall referenceで調べる
size 画面上の表示サイズ 定義方法がピクセルの場合と文字数の場合があるのでこちらのCall referenceで調べる
pad 要素周りの空白 上下左右の要素との距離を変えたい時に設定する
key 要素の名前 要素の識別子としてよく使う
・If event==key:でどの要素が選択や変更されたのかを調べる
・Values[key]で要素の現在の値や状態を調べる
・window[key].update()で要素の表示を変更する
enable_events その要素が選択されたり、変更された場合にwindow.read()に返すかどうか True / False
Trueにすることで、「インプットボックスに記入されたらすぐに取り込む」などができるようになる
  1. Windowの作成
    1で作成したLayoutをsg.Windowに渡してwindowインスタンスを作成します。
window = sg.Window('Window Title', layout, size=ウインドウサイズ, finalize=True)

これでGUIが表示されます。

  1. Whileループでイベント操作
    下記が基本形で、<>の部分にGUI操作に対するコードを記入します。
while True:

    event, values = window.read()

    if event == <GUI操作>:
        <GUI操作に対応するコード> 

    # if user closes window or clicks cancel
    if event == sg.WIN_CLOSED:
        break

window.close()

GUIが表示されている間は

event, values = window.read()

の行でGUI操作を待っています。ボタンを押すなどの操作が行われた場合、それに対応する内容がeventに、各要素の状態がvaluesに返って次の行以下が実行されます。if event〜で何の操作が実施されたかを判断して、操作に対応するコードをif文の下に書きます。テキストボックスへ入力された情報などはvaluesに格納されているので、valuesの情報を読み取ることで対話的な操作が実現できます。


✏️ちょっと凝ったものを作ると、Layout, while文それぞれが長くなってしまいます。今回作成したスケジュール管理プログラムでは、layoutとwhileから呼び出す関数達を一つのclassにしてファイルを分けて管理しました。
最初から仕様がしっかり固まっていれば問題ないのかもしれませんが、試行錯誤しながら作る場合はclassにして別ファイルにしておくのが良いと思います。
ここで紹介するコードは、次のようにlayoutの作成、windowの作成、window.read()それぞれをクラスの中に記載しています。

class ScheduleManage:
    def __init__(self):
        ファイル保存場所などの情報読み込み

    # レイアウト作成
    def _layout(self):
        self.layout = [[sg.-]]

    # window作成
    def create_window(self):
        layout = self._layout()
        self.window = sg.Window('Window Title', layout)

    # window.readを実行して eventの解析
    def parse_event(self):
        event, self.values = self.window.read()

    # 操作に対応する関数
    def ———-(self):
        ————-

メインの関数では、”sch_m”という名前で上記のクラスをインスタンス化して使用しています。

# sch_mという名前でインスタンスを生成
sch_m = ScheduleManage()
sch_m.create_window()

while True:
    # window.read()をして返ってきたeventから、画面上の場所と要素タイプ、付けているIDを調べて返します。
    event, pos, item, eid = sch_m.parse_event()
    
    # GUI操作に対応するコードの実行
    if pos == 画面上の場所:
        If item == 要素タイプ:
            If eid == 要素ID:
                sch_m.対応するメソッド() 

スケジュール管理プログラムのGUI

Header

Headerにはプロジェクト毎の表示を切り替えるチェックボックス、Saveしたりするボタン、表示するメンバーを切り替えるラジオボタンがあります。

レイアウト

PySimpleGUIでは要素をリストに入れて配置位置を決めますが、単純な2重リストでは実現できる配置に限界があります。そのため、layoutを格納できるColumnという要素が準備されています。Column要素を使うことで要素配置の自由度が大きくなります。
Headerの例では、チェックボックスを2重のリストに並べた「チェックボックスのlayout」を作成してColumn要素に渡しています。Columnは1つの要素として振る舞うので、このColumnをリストに並べることで、チェックボックスは4行でボタンは3行と異なる行数で横に並べることができます。

コードではdef _header_layout(self)メソッドの中に
(1) チェックボックス、ラジオボタン、ボタンの各要素を作成(レイアウトを気にせず必要な要素を作る)
(2) それらを並び替えてlayoutを作成し、Columnに格納
(3) Columnを並べたリストを作成
という順番で記述しています。ここからその詳細を説明します。

チェックボックスの作成
 チェックボックスのdefaultはチェックのON(True)/OFF(False)を決める引数です。✅の横に表示する文字列はtext引数になります。それ以外は「PySimpleGUIの基本的な使い方」の章で紹介した引数を指定しています。またチェックボックスのON/OFFを切り替えたらすぐに、対応する要素の表示切り替えを実行できるように、enable_eventsにTrueを渡しています。
 チェックボックスはまとめて値を取得することが多いので、For文で指定できるように名前を決めると後で使いやすいです。また、表示する項目数は存在するプロジェクトの個数によって変わるので、For文を回して要素を作成しています。

    def _header_layout(self):
        # check boxの作成
        # 1行が長くなるので、[text, default, key]のリストを先に作成
        cbx_list = [[name, True, f"-hd_cbx_{i:02d}-"] for i, name in enumerate(チェックボックス名一覧リスト)]
        # [text, default, key]を順番に取り出して要素の配列を作成(ここではレイアウトを考えていない)
        hd_cbx = [sg.Checkbox(text=name, default=tf, key=key, size=(20,1), pad=0, enable_events=True) for name, tf, key in cbx_list]

ラジオボタンの作成
 ラジオボタンもチェックボックスと同様ですが、ラジオボタンではgroup_idを指定する必要があります。同じgroup_idを持つラジオボタンは同時に2つ以上を選択できないようになります。

        # raido buttonの作成
        rdi_list = [[name, name==初期にONにする名前, f"-hd_rdi_{i:02d}-"] for i, name, in enumerate(メンバ一覧のリスト)]
        hd_rdi = [sg.Radio(name, group_id="hd_rdi", default=tf, key=key, size=(15,1), p=0, enable_events=True) for name, tf, key in rdi_list]

ボタンの作成
 ボタンは表示する文字列をbutton_textに渡します。ボタンを押したらeventが取得できるので、enable_eventsを設定する必要はありません。ボタンも連番になるようにkeyを決めていますが、ここはボタン名にした方が分かり易かったかもしれません。

        # buttonの作成
        btn_list = ["All", "Clear", "Refresh", "Upload", "Reload"]
        hd_btn = [sg.Button(name, key=f"-hd_btn_{i:02d}-", size=(10, 1)) for i, name in enumerate(btn_list)]

各要素をcolumnsに格納
 チェックボックス、ラジオボタン、ボタンの要素をそれぞれ2重のリストの中に入れて各レイアウトを作ります。そしてそれらを引数としてColumn要素を作成します。Columnの中で余白ができる場合は、element_justificationで左寄せ、中央、右寄せを、vertical_alignmentで、上寄せ、中央、下寄せを指定できます。

        # Columnに渡すlayoutを作成
        # チェックボックスとラジオボタンは4行で表示したいので、4個置きに取り出した行リストを4つ作成
        layout_cbx1 = [[cb for cb in hd_cbx[i::4]] for i in range(4)]
        layout_rdi1 = [[rd for rd in hd_rdi[i::4]] for i in range(4)]
        layout_btn1 = [[btn] for btn in hd_btn[0:3]]
        layout_btn2 = [[btn] for btn in hd_btn[3:5]]

        # Columnを作成
        hd_cl1 = sg.Column(layout_cbx1, size=(960, 100))
        hd_cl2 = sg.Column(layout_btn1, size=(160, 100), vertical_alignment="center")
        hd_cl3 = sg.Column(layout_btn2, size=(160, 100), vertical_alignment="center")
        hd_cl4 = sg.Column(layout_rdi1, size=(320, 100))

Columnを並べたリストの作成
 作成したColumn要素を1つのリストに並べます。これで各レイアウトが1行に並べて表示されます。これでヘッダー部分のレイアウトは完成です。

        # header layout
        hd = [hd_cl1, hd_cl2, hd_cl3, hd_cl4]
        return hd

チェックボックスとラジオボタンの操作

チェックボックスやラジオボタンの要素を作るときにenable_eventsをTrueとしているので、チェックボタンやラジオボタンをクリックすると、window.read()の待機が終わり、event, valuesが返ってきます。

    event, values = window.read()
    print(event)

例えば一番左上のチェックボックスをクリックした場合、eventには"-hd_cbx_00-"(keyで指定した文字列)が入った状態で、window.read()以降のコードが実行されます。上記の例では、print文が実行されて次の文字列が書き出されます。

-hd_cbx_00-

このeventに対応して計画表の表示/非表示を実行することで、チェックボックスのON/OFFで計画表をON/OFFする機能を実現できます。計画表のON/OFFについては次の章で説明します。

valuesにはクリックしたチェックボックスだけでなく、全ての要素の状態が辞書型で入っています。

{'-hd_cbx_00-': False, '-hd_cbx_01-': True, '-hd_rdi_00-': True, '-hd_rdi_01-': False, '-hd_rdi_02-': False}

ボタンのように状態が無い要素は含まれません。ちなみにインプットボックスにはインプットされた文字列が、テーブルであれば選択されている行番号リストが値として入っています。
このvaluesの中身を調べることで、各チェックボックスやラジオボタンの状態を調べることができます。

    for i in range(チェックボックスの数):
        print(values[f"-hd_cbx_{i:02d}-"])
    for i in range(ラジオボタンの数):
        print(values[f"-hd_rdi_{i:02d}-"])

 window[key].update()を使うと各要素の状態を更新することができます。ALLボタンを押したら全てのチェックボックスをONに、Clearボタンを押したら全てのチェックボックスにOFFにするには、次のように記載します。

    # ALLボタンが押された場合
    if event == "-hd_btn_00-":
        for i in range(2):
            values[f"-hd_cbx_{i:02d}-"] = True
            window[f"-hd_cbx_{i:02d}-"].update(value=True)
    
    # Clearボタンが押された場合
    if event == "-hd_btn_01-":
        for i in range(2):
            values[f"-hd_cbx_{i:02d}-"] = False
            window[f"-hd_cbx_{i:02d}-"].update(value=False)

 valuesは変更しなくても画面表示は切り替わりますが、次のwindow.read()をするまで値は書き変わらないことには注意が必要です。window[key].update()の下に処理を続けて書く場合は、事故防止のためにvaluesも更新しておくのがいいと思います。

 ラジオボタンの変更も同様に記述できます。例えば1番目のラジオボタンをオンにする場合は次のようにします。対象となるwindow[key].update()をすれば、自動的に他の要素のチェックが外れます。

    if MEMBER1_ACTIVATE:
        window[f"-hd_rdi_01-"].update(value=True)
        # valuesを更新する場合
        for i in range(ラジオボタンの数):
            values[f"-hd_rdi_{i:02d}-"] = i==1

なお、valuesの更新はwindow.read()を用いてもできます。引数に何も設定しないとGUI操作待ち状態になりますが、timeout=0としておくことで、直ちに値が返ってきます。

    _, values = window.read(timeout=0)

少し余談になりますが、window[key]はkey要素オブジェクトにアクセスしていることになります。

    print(window["-hd_cbx_00-"])

<PySimpleGUI.PySimpleGUI.Checkbox object at xxxxxxxxxxx>

なので、チェックボックスの更新などは、作成した要素のリストを保存しておいて、以下のように書くこともできます。
スケジュール管理プログラムの中で何度か使っていますが、可読性はwindow[key]の方が良いかと思います。

    self.hd_cbx = [sg.Checkbox(text=name, default=tf, key=key, size=(20,1), pad=0, enable_events=True) for name, tf, key in cbx_list]
    
    for cbx in self.hd_cbx:
        cbx.update(value=True)

左側グループ Project(計画表)タブ


タブグループの設定

作成したGUIでは左右にタブグループを作成しています。
それぞれのタブの中身のレイアウトはこれまで通り2重リストで作成します。
作成したレイアウトをsg.Tabに渡し、sg.Tabをsg.TabGroupに渡すことでタブが作成されます。

   l1 = sg.Tab(2重リストのレイアウト)
   l2 = sg.Tab(2重リストのレイアウト)
   sg.TabGroup([[l1], [l2]])

レイアウト

Projectタブでは各プロジェクト毎に入れ物を作成し、その中にTicket(細切れ化した仕事)を並べて表示しています。
各Ticketは任意の座標に配置したいですが、PySimpleGUIの要素を座標指定で配置することは難しい(できない?)ので、
sg.Graphで図形や文字を自由に描画できる要素を配置し、その中にTicketを並べていきます。

少し複雑に見えますが、tab, column, frameはそれぞれ引数に2重リストのレイアウトを取るので、
レイアウトを作成して順番に入れているだけになります。

   tab = sg.Tab([[column]])
   column = sg.Column([[frame1], [frame2]])
   frame1 = sg.Frame([[graph1]])
   graph1 = sg.Graph()

それぞれの要素を使用している理由は、次の通りです。

  • sg.Graph
    自由に図形と文字を並べるために作成しています。
  • sg.Frame
    各プロジェクトの塊毎に左上にタイトルを表示させるために使用しています。
    また、プロジェクト毎に表示/非表示を切り替える時にもframeのvisibleオプションを使っています。
    どちらもGraphでも実施できるとは思いますが、最初に外枠を付けたり試行錯誤していた名残で残しています。
  • sg.Column
    引数で "scrollable=True" とすることでスクロールバーを設定できます。
    縦も横もどちらも入り切らないことが想定されますが、Columnに渡しておくことでスクロールして全体を見ることができるようになります。

作成したプログラムではこちらのように書いています。
1番上の列に日付を表示していますが、これもsg.Graphに書いています。
(sg.Textを並べることを試みましたが、sg.Graphとsg.Textで一日の幅を合わせる方法が分からず断念しました。また、縦スクロールをしても常に日付が1番上に表示されるように、日付とticketを別々のColumn要素に入れるつもりだったのですが、同じColumn要素に入れています。横スクロールの同期のやり方がわからず、横にスクロールすると日付とticketががずれてしまうためです。)

    def _l1_layout(self):

        # sg.Graphエリアで使う座標の設定。左下と右下の座標値。自分が分かりやすければなんでも良い。
        gbl = (0, 0)
        gtr = (10000, 100)

        # 1番上の日付表示エリア
        # ticket表示エリアと左端を揃えるために、frameに入れる
        self.l1_grp_cal = sg.Graph((self.sizes["left_tab1_canvas"][0],10), gbl, gtr)
        self.l1_frm_cal = [[sg.Frame("", [[self.l1_grp_cal]], border_width=0)]]

        # ticket表示エリア
        # プロジェクト毎にgraph要素を作成し、frame要素に入れる
        grp_rclick_menu = ["menu", ["Scheduling_", "Edit_", "New ticket FROM this_", "New ticket TO this_"] + ["Status", [f"{s}_" for s in self.param["status"]]]]
        self.l1_grp = [sg.Graph(self.sizes["left_tab1_canvas"], gbl, gtr, pad=0, background_color="#7f7f7f", key=f"-l1_grp_{i:02d}-", right_click_menu=grp_rclick_menu, enable_events=True) for i in range(len(self.prj))]
        self.l1_frm = [[sg.Frame(p, [[g]], key=f"-l1_frm_{i:02d}-", border_width=0)] for i, (p, g) in enumerate(zip(self.prj, self.l1_grp))]
        # まとめてColumn要素に入れる
        self.l1_clm = [sg.Column(self.l1_frm_cal + self.l1_frm, scrollable=True, vertical_scroll_only=False, size=self.sizes["left_tab_group"])]

        l1 = [sg.Tab("Project", [self.l1_clm], key="-l1_tbg_00-")]
        return l1

詳しい設定は後ほど説明しますが、sg.Graphの引数で、right_click_menuを設定しています。また、マウスオーバーで詳細を表示できるように(マウスの位置を取得できるように)、次の設定を追加でしています。要素上でマウスが動いた場合と、左クリックされた場合にeventを返すように、Graph要素にbindします。下のコードのself.l1_grpは上のコードで作成したGraph要素が入ったリストです。

    def _bind_items(self):
        for grp in self.l1_grp:
            grp.bind("<Motion>", "MV-")
            grp.bind("<ButtonPress>", "LC-")

なお、このbind処理はwindowのfinalize後でないとできませんので、finalize後にbind設定を呼び出しています。

    self.window = sg.Window(省略, finalize=True)
    self._bind_items()

Ticketの表示

各チケットの位置情報は別の関数で計算しています。ここでは表示する部分のみを示します。Graph要素には図形を表示したり、文字を表示したりできるメソッドが準備されています。

    for prj_id in range(プロジェクト数):
        # 一度グラフ要素上の表示を削除して初期化
        # self.l1_grpはGraph要素の入ったリスト。
        self.l1_grp[prj_id].erase()
        for チケットの位置や内容 in リスト[prj_id]:
            # draw_rectangleは長方形を描くメソッド
            self.l1_grp[prj_id].draw_rectangle((x0, y0),(x1, y1), fill_color="#505050", line_color="#606060", line_width=1)
            # draw_textは文字列を表示させるメソッド
            self.l1_grp[prj_id].draw_text(f" {task}-{ticket}", (x0, y0+5), color="#eeeeee", font=self.param["font"], text_location=sg.TEXT_LOCATION_LEFT)
            self.l1_grp[prj_id].draw_text(f" {in_charge} ({traker:2.2f}/{estimation:2.2f})", (x0, y0-5), color="#eeeeee", font=(self.param["font"][0], self.param["font"][1]-2), text_location=sg.TEXT_LOCATION_LEFT)

他にも円や線を描くメソッド、図を表示させるメソッドもあります。詳しくはPySimpleGUI Call referenceのGraph Elementの項目を確認して下さい。

ここでメソッドのtop_leftやlocationなどで指定する位置情報は、sg.Graphのgraph_bottom_left, graph_top_rightで指定した座標系で示す座標値になります。Graph要素では左下と右上を指定して座標系を指定しますが、長方形を描くときは左上と右下の座標を指定します。何を指定するかはしっかりとCall referenceを確認が必要です。

スケジュール管理プログラムでは、ticketの開始時間と終了時刻から長方形を描き、その中に、task名とticket名、担当者、実績時間/見積もり時間を書いています。


表示/非表示

各プロジェクト毎に表示されたticketは、上のチェックボックスで表示非表示を切り替えが可能です。

チェックボックスのON/OFFに応じて、frameのunhide_row(), hide_row()メソッドを実行します。

    eid = チェックボックスとフレームの要素ID
    if self.hd_cbx[eid].get():
        # チェックボックスがONの場合
        self.l1_frm[eid][0].unhide_row()
    else:
        # チェックボックスがOFFの場合
        self.l1_frm[eid][0].hide_row()

unhide_rowメソッドを用いると、そのframeを配置している行全体が非表示(無視される)ので、その行より下の行が自動的に繰り上がって上詰で表示されます。


マウスオーバーでの詳細表示

Projectタブではticketの詳細までは見ることができません。そこで、対象となるticketの上にマウスカーソルを移動させると、右のタブに詳細な内容が表示されるようになっています。

これはレイアウトの部分で紹介した .bind("", "MV-") の設定によって実現できます。
Graph要素にマウスの動きを捉えるよう指示する設定であるため、Graph要素上でマウスを動かすと、

    event, values = window.read()

での待機が終了し、eventに sg.Graphで指定したkey + bindで指定した文字列がつながった文字列が返ってきます。
本プログラムの場合は、sg.Graphのkeyが"—lt_grp_01-" で .bindで指定した文字列が"MV-"なので、
evnet = “-lt_grp_01-MV-”となります(01はGraph要素のIDです)。
このeventの文字列から、左側のタブにあるグラフ要素ID=1の上でマウスが動いたことが判断できます。

マウスの座標は、グラフ要素.user_bind_event.x / yで調べることができます。この値は、要素の左上からの距離(ピクセル)になります。要素の幅と高さで除して、グラフ座標系の最大幅と高さをかけて、座標系を変換しています。
これでマウスがグラフのどの位置にあるかが分かるので、その位置に対応するticketの情報を読み取って、右の詳細表示エリアに表示してあげれば良いです。

    self.mouse_x = int(self.l1_grp[eid].user_bind_event.x / self.sizes["left_tab1_canvas"][0] * self.sizes["graph_top_right"][0])
    # このプログラムではyは見ていないので、本当はなくてもいい
    self.mouse_y = int(self.l1_grp[eid].user_bind_event.y / self.sizes["left_tab1_canvas"][1] * self.sizes["graph_top_right"][1])

    # self.graph_positions has each ticket start y coordinate
    pos_index = bisect.bisect_left(self.graph_positions[eid], self.mouse_x)
    ticket_id = self.graph_ticket_ids[eid][pos_index-1]

右クリックメニュー

右クリックメニューは要素を作成するときに、引数"right_click_menu"を設定すると作成することができます。
下記のコードでは、右クリックメニューが長くなるので、一度"grp_rclick_menu"という変数に格納してから、要素の引数に渡しています。右クリックメニューは["menuの名前", ["項目1", "項目2"...]]という2重リストの形式で定義します。0番目はメニューの名前(どこで使われているのか分かっていません。分かったら追記します)で1番目にメニューの選択項目をリストで定義します。メニューの項目を選択すると、さらにメニューが出るようにしたい場合は(上図ではStatusを選択すると、さらにメニューが表示される)、項目の部分にリストで定義します。

    # 右クリックメニュー
    grp_rclick_menu = ["menu", ["Scheduling_", "Edit_", "New ticket FROM this_", "New ticket TO this_"] + ["Status", ["ToDo", "Doing", "Checking", "Done", "Pending"]]]
    sg.Graph(size, bottom-left, top-right, key=f"-l1_grp_{i:02d}-", right_click_menu=grp_rclick_menu, enable_events=True)

右クリックメニューをクリックすると、クリックしたメニューの項目名がeventに返ってきます。
上図の"Scheduleing_"をクリックすると、eventには"Scheduling_"が返ってきます。
eventからどのボタンが返ってきたのかを判断しやすいように、項目名の最後に"_"をつけています。
(メニューボタンが押されたと判断する、もっといいやり方があると思うのですが・・・すみません)


左側グループPriorityタブ


そのうち書きます。

10
11
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
10
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?