Qiitaにログインしてダークテーマを使ってみませんか?🌙

ログインするとOSの設定にあわせたテーマカラーを使用できます!

92

CustomTkinter で作るおしゃれな Python GUI & フレームを活用したGUI作成チュートリアル

最終更新日 投稿日 2023年02月18日

1. はじめに

 Python で GUI を作成するとき Tkinter を使用することが多いと思いますが、デザインがちょっと古臭いのが気になる... ただ、PyQt や kivy を勉強するのも大変そうだということで、おしゃれにちょうどいい感じの学習量で使える TomSchimansky/CustomTkinter を使って GUI を作ってみました。

実際に作ってみた GUI は以下のものになります。

main.png

動かしてみると以下のようになります。

demo.gif

久しぶりに Python で GUI を作成したこともあって、初心者向けにどうやって GUI を作成していくかの流れをまとめてみましたが、熟練者でサクッとコードだけを見たい方は まとめ までスキップしてください。

2. GUI 作成チュートリアル

2.1. 開発環境

  • Windows 11
  • Python 3.8.10 (および 3.10.9 で動作確認済み)
使用したパッケージ (requirements.txt) はこちら
requirements.txt
certifi==2022.12.7
charset-normalizer==3.0.1
contourpy==1.0.7
customtkinter==5.1.2
cycler==0.11.0
darkdetect==0.8.0
fonttools==4.38.0
idna==3.4
kiwisolver==1.4.4
matplotlib==3.6.3
numpy==1.24.2
packaging==23.0
pandas==1.5.3
Pillow==9.4.0
pyparsing==3.0.9
python-dateutil==2.8.2
pytz==2022.7.1
requests==2.28.2
six==1.16.0
urllib3==1.26.14

2.2. ここで紹介したいこと

 Python の Tkinter(CustomTkinterも)はコードベースで GUI を作成していくのですが、慣れていないと非常に作成に苦労します。

 Tkinter で一度 GUI を作ったことがあるけれど、苦労したなぁという人を対象に

  • 頭でイメージしたデザインをどう作るか
  • 加えて開発後にメンテナンスしやすい構成にする

ということを考えて作ってみたので、その方法を紹介したいと思います。

2.3. CustomTkinter の基本のコード

 まず、CustomTkinter をインストールします。

$ pip install customtkinter

CustomTkinter の書き方は Tkinter とよく似ていて、例えばテキスト入力欄とボタンがあるようなシンプルな以下のような GUI の場合、

image.png

コードは以下のようになります。

import customtkinter

FONT_TYPE = "meiryo"

class App(customtkinter.CTk):

    def __init__(self):
        super().__init__()

        # メンバー変数の設定
        self.fonts = (FONT_TYPE, 15)
        # フォームサイズ設定
        self.geometry("350x200")
        self.title("Basic GUI")

        # フォームのセットアップをする
        self.setup_form()
    
    def setup_form(self):
        # CustomTkinter のフォームデザイン設定
        customtkinter.set_appearance_mode("dark")  # Modes: system (default), light, dark
        customtkinter.set_default_color_theme("blue")  # Themes: blue (default), dark-blue, green

        # テキストボックスを表示する
        self.textbox = customtkinter.CTkEntry(master=self, placeholder_text="テキストを入力してください", width=220, font=self.fonts)
        self.textbox.place(x=60, y=50)

        # ボタンを表示する
        self.button = customtkinter.CTkButton(master=self, text="クリックしてね", command=self.button_function, font=self.fonts)
        self.button.place(x=100, y=100)
    
    def button_function(self):
        # テキストボックスに入力されたテキストを表示する
        print(self.textbox.get())


if __name__ == "__main__":
    # アプリケーション実行
    app = App()
    app.mainloop()

上記サンプルでは、ウィジェットの配置に place を使用していますが、placeは極力使用せず、あとで紹介する grid や pack を使用することをおすすめします。

コードの補足

  • 各ウィジェットの書き方は公式の wiki が詳しいです。

  • フォントは FONT_TYPE = "meiryo" の部分でメイリオに設定しています。Windows以外の開発環境の場合は適切なフォントに変更してください。

  • GUI のフォームデザインの切り替えは以下の部分で変更することができ,dark から light モードに変更すると、以下のようになります。

    # CustomTkinter のフォームデザイン設定
    customtkinter.set_appearance_mode("dark")  # Modes: system (default), light, dark
    customtkinter.set_default_color_theme("blue")  # Themes: blue (default), dark-blue, green
    

    image.png

2.4. 作りたい GUI のデザイン

 以下が作成前にデザインした GUI イメージになります。今から、このデザインの GUI を作っていこうと思います。

sample_design.png

以降は追加のパッケージとして matplotlib, pandas, numpy が必要なので、以下のようにインストールしてください。

$ pip install matplotlib pandas numpy

2.5. grid と Frame について

 ウィジェットを GUI に配置する際に、2.3 で紹介した基本のコードのように place を使用して一つずつウィジェットの位置を指定すると、ちょっとボタンの配置を変えたくなったり、フォントサイズを変更したくなったときなどに、泣きながら変更対応することになります。
 そこでここでは gridFrame(フレーム) を使用していきたいと思います。

2.5.1. grid って?

 下記図のように、ウィンドウをグリッド分割して、列, 行(Col, Row)で指定した位置にウィジェットを置くことができます。

grid.png

例えば以下のように、ウィジェットを配置する際に place のかわりに grid を使用することで、

# ボタンを表示する
self.textbox1 = customtkinter.CTkEntry(master=self, placeholder_text="テキストボックス1", width=150, font=self.fonts)
self.textbox1.grid(row=0, column=0, padx=10, pady=20)

self.button1 = customtkinter.CTkButton(master=self, text="ボタン1", command=self.button_function, font=self.fonts)
self.button1.grid(row=0, column=1, padx=10, pady=20)

self.textbox2 = customtkinter.CTkEntry(master=self, placeholder_text="テキストボックス2", width=150, font=self.fonts)
self.textbox2.grid(row=1, column=0, padx=10, pady=20)

self.button2 = customtkinter.CTkButton(master=self, text="ボタン2", command=self.button_function, font=self.fonts)
self.button2.grid(row=1, column=1, padx=10, pady=20)

place のようにウィジェットの大きさを意識することなく、ウィジェットを指定した場所に配置してくれます。

image.png

2.5.2. フレーム を使用してグループ分けする

 grid を使用して一つずつウィジェットを配置していこうと思いますが、少し GUI のデザインが複雑だと行と列にきれいに分けることもできず grid をそのまま適用するのも難しくなるので、フレーム (Frame) を使ってグループ分けしたいと思います。

 さきほどイメージした GUI を点線で表示したように、2×1 の2個のフレームとなるように区切ます。

sample_design2.png

以降のコードでは、このようにフレームで区切ったあとに、フレームの中のウィジェットを grid で配置していきたいと思います。

2.6. フレームを順に作る

2.6.1. 1つ目のフレームの作成

 フレームの作成には、CustomTkinter の customtkinter.CTkFrame を使用します。ここでは GUI 上部にあるファイルを開く機能をフレーム (ReadFileFrame) で作成していきます。

 先にコードを紹介して、あとから補足します。

import tkinter as tk
import customtkinter
import os

FONT_TYPE = "meiryo"

class App(customtkinter.CTk):

    def __init__(self):
        super().__init__()

        # メンバー変数の設定
        self.fonts = (FONT_TYPE, 15)
        self.csv_filepath = None

        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        # CustomTkinter のフォームデザイン設定
        customtkinter.set_appearance_mode("dark")  # Modes: system (default), light, dark
        customtkinter.set_default_color_theme("blue")  # Themes: blue (default), dark-blue, green

        # フォームサイズ設定
        self.geometry("1000x300")
        self.title("CSV plot viewer")

        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(1, weight=1)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(0, weight=1)

        # 1つ目のフレームの設定
        # stickyは拡大したときに広がる方向のこと。nsew で4方角で指定する。
        self.read_file_frame = ReadFileFrame(master=self, header_name="ファイル読み込み")
        self.read_file_frame.grid(row=0, column=0, padx=20, pady=20, sticky="ew")

class ReadFileFrame(customtkinter.CTkFrame):
    def __init__(self, *args, header_name="ReadFileFrame", **kwargs):
        super().__init__(*args, **kwargs)
        
        self.fonts = (FONT_TYPE, 15)
        self.header_name = header_name

        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(0, weight=1)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(0, weight=1)

        # フレームのラベルを表示
        self.label = customtkinter.CTkLabel(self, text=self.header_name, font=(FONT_TYPE, 11))
        self.label.grid(row=0, column=0, padx=20, sticky="w")

        # ファイルパスを指定するテキストボックス。これだけ拡大したときに、幅が広がるように設定する。
        self.textbox = customtkinter.CTkEntry(master=self, placeholder_text="CSV ファイルを読み込む", width=120, font=self.fonts)
        self.textbox.grid(row=1, column=0, padx=10, pady=(0,10), sticky="ew")

        # ファイル選択ボタン
        self.button_select = customtkinter.CTkButton(master=self, 
            fg_color="transparent", border_width=2, text_color=("gray10", "#DCE4EE"),   # ボタンを白抜きにする
            command=self.button_select_callback, text="ファイル選択", font=self.fonts)
        self.button_select.grid(row=1, column=1, padx=10, pady=(0,10))
        
        # 開くボタン
        self.button_open = customtkinter.CTkButton(master=self, command=self.button_open_callback, text="開く", font=self.fonts)
        self.button_open.grid(row=1, column=2, padx=10, pady=(0,10))

    def button_select_callback(self):
        """
        選択ボタンが押されたときのコールバック。ファイル選択ダイアログを表示する
        """
        # エクスプローラーを表示してファイルを選択する
        file_name = ReadFileFrame.file_read()

        if file_name is not None:
            # ファイルパスをテキストボックスに記入
            self.textbox.delete(0, tk.END)
            self.textbox.insert(0, file_name)

    def button_open_callback(self):
        """
        開くボタンが押されたときのコールバック。暫定機能として、ファイルの中身をprintする
        """
        file_name = self.textbox.get()
        if file_name is not None or len(file_name) != 0:
            with open(file_name) as f:
                data = f.read()
                print(data)
            
    @staticmethod
    def file_read():
        """
        ファイル選択ダイアログを表示する
        """
        current_dir = os.path.abspath(os.path.dirname(__file__))
        file_path = tk.filedialog.askopenfilename(filetypes=[("csvファイル","*.csv")],initialdir=current_dir)

        if len(file_path) != 0:
            return file_path
        else:
            # ファイル選択がキャンセルされた場合
            return None


if __name__ == "__main__":
    app = App()
    app.mainloop()

実行すると以下のような GUI になります。

image.png

コードの補足

  • customtkinter.CTkFrame を継承したクラス ReadFileFrame でフレームを作成しています。フレームの中の作り方(ウィジェットの配置やコールバックの設定)は、通常のウインドウアプリのように書けばよいです。このクラスをウィジェットのように親フレーム(=アプリケーションのトップクラス App)から呼び出し、grid で配置して使用します。

  • grid_rowconfiguregrid_columnconfigure を使用した以下の部分ですが、GUI をリサイズしたときの動作設定になります。今回 GUI 全体を横に広げたときに2つのフレームも横に広げ、縦に広げたときは、2つ目のフレームだけ一緒に縦に広がるようにしたいです。フレームの大きさを GUI のリサイズに連動させたい Col, Row 番号に weight=1 を設定します。

    # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
    self.grid_rowconfigure(1, weight=1)
    # 列方向のマスのレイアウトを設定する
    self.grid_columnconfigure(0, weight=1)
    

    上記のように設定したあとに、grid で配置する際の設定して sticky="ew" のように指定すると横に広がるように設定できます。

    self.textbox.grid(row=1, column=0, padx=10, pady=(0,10), sticky="ew")
    

sticky は NEWS の4方角で設定するようになっており、例えばウィンドウ拡大時にウィジェットを横に広げてほしいときはsticky="ew" 、縦に広がってほしいときは sticky="ns" とします。

  • 2つのボタンはコールバック関数(button_select_callback, button_open_callback)でそれぞれエクスプローラーからファイルを選択、選択されたファイルを開くように設定しています。「開く」ボタン(button_open_callback) については、このあと2つ目のフレームのプロット機能と連動させる必要があるのですが、ここでは仮の機能で実装しています。

2.6.2. プロット機能の作成

 2つ目のフレームの作成の前に、2つ目のフレームで使用するプロット機能を管理するクラスを作成します。

ここでは新しいファイル (plot_control.py) を作成してコードを分けます。
わざわざ分ける理由としては(機能の動作確認するときにいちいち UI を叩くのが面倒くさいというのもありますが)

  • 単体テストを実施しやすくする
  • 機能拡張が容易になる

です。ちゃんとした開発を行う際には UI と機能のコードは開発初期から分けていた方がよいです。

plot_control.py
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import os

class PlotControl:
    """
    matplotlib でプロットを行う
    """
    def __init__(self):
        """
        コンストラクタ
        """
        # figの作成
        self.fig = plt.figure()
        # 座標軸の作成
        self.ax = self.fig.add_subplot(1, 1, 1)
        # コンフィグ設定
        self.config = {"linewidth":2, "linetype":"line"}
        self.filepath = None
        self.df = None

    def replot(self, filename=None, config=None):
        """
        プロットを更新する
        """

        # ファイル名が設定されていれば、データを更新する
        if filename is not None:
            # ファイルデータを読み取る
            self.filepath = filename
            self.df = pd.read_csv(filename)
        if self.df is None:
            return

        # コンフィグが設定されていれば、更新する
        if config is not None:
            self.config.update(config)
        
        # 重ねて描画しないように一度消す
        self.ax.clear()

        # plotする
        # plotの線種の種類をGUIから変更できるようにする。
        if self.config["linetype"] == "line + marker":
            self.ax.plot(self.df["time"], self.df["y"], "o-", linewidth=self.config["linewidth"])
        elif self.config["linetype"] == "dashed":
            self.ax.plot(self.df["time"], self.df["y"], "--", linewidth=self.config["linewidth"])
        else:
            self.ax.plot(self.df["time"], self.df["y"], linewidth=self.config["linewidth"])

        self.ax.set_xlabel("time")
        self.ax.set_ylabel("y")

    def save_fig(self, export_path=None):
        """
        figをファイルに保存する
        """
        # ファイル名が設定されていないときは、csvファイル名から拡張子だけを変えたものとする
        if export_path is None and self.filepath is not None:
            file, ext = os.path.splitext(self.filepath)
            export_path = f"{file}.png"

        if export_path is None:
            return
        
        self.fig.savefig(export_path)
        return export_path

if __name__ == "__main__":
    # サンプルcsvを出力する
    filename = "sample_data.csv"
    time = np.linspace(0.0, 3.0)
    y = np.sin(2*np.pi*time)
    out_df = pd.DataFrame(data={"time": time, "y": y})
    out_df.to_csv(filename)

    # 通常のグラフを描画する
    plot_control = PlotControl()
    plot_control.replot(filename)
    plot_control.save_fig("test1.png")

    # 線種を変更する
    config = plot_control.config.copy()
    config["linewidth"] = 5
    plot_control.replot(config=config)
    plot_control.save_fig("test2.png")

コードの補足

  • このコードは、あとで GUI で呼び出されることを前提として、以下のような中身になっています。

    1. csvファイルから pandas データフレームにデータを読み込む
    2. matplotlibで、プロットする
    3. プロットを png ファイルに出力する

2.6.3. 2つ目のフレームの作成

 つづいて、GUI 下半分のプロット部分のフレームを作っていきたいと思います。左側に配置するプロット調整用のウィジェットは数が多いので、以下の図のようにさらにサブフレームに分割しました。

sample_design3.png

フレーム部分のみのコードは以下のようになります。

import tkinter as tk
import customtkinter
import os
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from plot_control import PlotControl

class PlotMainFrame(customtkinter.CTkFrame):
    """
    プロットを表示するメインフレーム
    """
    def __init__(self, *args, header_name="PlotMainFrame", **kwargs):
        super().__init__(*args, **kwargs)
        
        self.fonts = (FONT_TYPE, 15)
        self.header_name = header_name
        # プロット機能をインポート
        self.plot_control = PlotControl()

        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        """
        フォームデザインのセットアップ
        """

        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(0, weight=1)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(1, weight=1)

        # 左側のGUI調整ボタンを ウィジェットとしてインポートする
        self.plot_edit_frame = PlotConfigFrame(master=self, header_name="プロット設定", plot_config=self.plot_control.config)
        self.plot_edit_frame.grid(row=0, column=0, padx=20, pady=20, sticky="ns")

        # プロットをキャンバスに貼り付ける
        self.canvas = FigureCanvasTkAgg(self.plot_control.fig,  master=self)
        self.canvas.get_tk_widget().grid(row=0,column=1, padx=20, pady=20, sticky="nsew")

        # 保存ボタンを置く
        self.button_save = customtkinter.CTkButton(master=self, command=self.button_save_callback, text="保存", font=self.fonts)
        self.button_save.grid(row=0, column=2, padx=10, pady=20, sticky="s")   

    def update(self, csv_filepath=None, config=None):
        """
        プロット描画を更新する
        """
        self.plot_control.replot(csv_filepath, config)
        self.canvas.draw()
    
    def button_save_callback(self):
        """
        保存ボタンが押されたときのコールバック。pngに出力する
        """
        filepath = self.plot_control.save_fig()
        if filepath is not None:
            tk.messagebox.showinfo("確認", f"{filepath} に出力しました。")

class PlotConfigFrame(customtkinter.CTkFrame):
    """
    プロットを線種を調整するサブフレーム
    """
    def __init__(self, *args, header_name="PlotConfigFrame", plot_config=None, **kwargs):
        super().__init__(*args, **kwargs)
        
        self.fonts = (FONT_TYPE, 15)
        self.header_name = header_name
        # plog のコンフィグ設定をコピーする
        self.plot_config = plot_config.copy()
        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        """
        フォームデザインのセットアップ
        """
        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(0, weight=0)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(0, weight=1)

        self.label = customtkinter.CTkLabel(self, text=self.header_name, font=(FONT_TYPE, 11))
        self.label.grid(row=0, column=0, padx=20, sticky="nw")

        # 線の太さを選択する
        self.slider_label = customtkinter.CTkLabel(self, text="ライン幅 2.5", font=(FONT_TYPE, 13))
        self.slider_label.grid(row=1, column=0, padx=20, pady=(20,0), sticky="ew")

        self.slider = customtkinter.CTkSlider(master=self, from_=0.5, to=5, number_of_steps=9, hover=False, width=150, command=self.slider_event)
        self.slider.grid(row=2, column=0, padx=20, pady=(0,20), sticky="ew")

        # 線の種類を選択する
        self.combobox_label = customtkinter.CTkLabel(self, text="線種", font=(FONT_TYPE, 13))
        self.combobox_label.grid(row=3, column=0, padx=20, pady=(20,0), sticky="ew")

        self.combobox = customtkinter.CTkComboBox(master=self, font=self.fonts,
                                     values=["line", "dashed", "line + marker"],
                                     command=self.combobox_callback)
        self.combobox.grid(row=4, column=0, padx=20, pady=(0,20), sticky="ew")

    def slider_event(self, value):
        """
        スライダーで線の太さを変更するときのコールバック
        """
        # マウスでバーを動かすと、何回も更新がかかることがあるので、数値に変化があったか確認する
        old_label = self.slider_label.cget("text")
        new_label = f"ライン幅 {value}"
        if old_label != new_label:
            # 値に変更があったときのみ更新をかける
            self.slider_label.configure(text=new_label)
            self.plot_config["linewidth"] = value
            self.master.update(config=self.plot_config)
    
    def combobox_callback(self,value):
        """
        プルダウンで線種を変更するときのコールバック
        """
        self.plot_config["linetype"] = value
        self.master.update(config=self.plot_config)

コードの補足

  • 前節で作成したプロット機能 plot_control.py をメインフレーム(PlotMainFrame) でインポートして使用します。
    from plot_control import PlotControl
    
    # プロット機能をインポート
    self.plot_control = PlotControl()
    
  • プロットを GUI のキャンバスに張り付けるときには、matplotlib の FigureCanvasTkAgg を使用します。
    # プロットをキャンバスに貼り付ける
    self.canvas = FigureCanvasTkAgg(self.plot_control.fig,  master=self)
    self.canvas.get_tk_widget().grid(row=0,column=1, padx=20, pady=20, sticky="nsew")
    
  • サブフレーム (PlotConfigFrame) のウィジェットから値を更新したときに、メインフレームにあるプロット描画を更新したいです。そこでメインフレームの方に update という関数を用意し、プロット描画を更新したときはサブフレームからこの update を呼び出すようにしました。

2.6.4. 2つ目のフレームのインポート

 前節で作成したプロット用のフレームを、アプリケーショントップ (App) からインポートして使用します。

 1つ目のフレームを作成した時点との diff を以下に表示します。

import tkinter as tk
import customtkinter
import os
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
+ from plot_control import PlotControl


FONT_TYPE = "meiryo"

class App(customtkinter.CTk):

    def __init__(self):
        super().__init__()

        # メンバー変数の設定
        self.fonts = (FONT_TYPE, 15)
        self.csv_filepath = None

        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        # CustomTkinter のフォームデザイン設定
        customtkinter.set_appearance_mode("dark")  # Modes: system (default), light, dark
        customtkinter.set_default_color_theme("blue")  # Themes: blue (default), dark-blue, green

        # フォームサイズ設定
+       self.geometry("1000x600")
        self.title("CSV plot viewer")
+       self.minsize(300, 400)

        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(1, weight=1)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(0, weight=1)

        # 1つ目のフレームの設定
        # stickyは拡大したときに広がる方向のこと。nsew で4方角で指定する。
        self.read_file_frame = ReadFileFrame(master=self, header_name="ファイル読み込み")
        self.read_file_frame.grid(row=0, column=0, padx=20, pady=20, sticky="ew")

+       # 2つ目のフレームの設定
+       self.plot_main_frame = PlotMainFrame(master=self, header_name="プロット表示")
+       self.plot_main_frame.grid(row=1, column=0, padx=20, pady=(0,10), sticky="nsew")
+
+   def update_canvas(self, csv_filepath=None):
+       """
+       描画の更新を指令する
+       """
+       if csv_filepath is not None:
+           self.csv_filepath = csv_filepath
+       self.plot_main_frame.update(csv_filepath=self.csv_filepath)



class ReadFileFrame(customtkinter.CTkFrame):
    def __init__(self, *args, header_name="ReadFileFrame", **kwargs):
        super().__init__(*args, **kwargs)
        
        self.fonts = (FONT_TYPE, 15)
        self.header_name = header_name

        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(0, weight=1)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(0, weight=1)

        # フレームのラベルを表示
        self.label = customtkinter.CTkLabel(self, text=self.header_name, font=(FONT_TYPE, 11))
        self.label.grid(row=0, column=0, padx=20, sticky="w")

        # ファイルパスを指定するテキストボックス。これだけ拡大したときに、幅が広がるように設定する。
        self.textbox = customtkinter.CTkEntry(master=self, placeholder_text="CSV ファイルを読み込む", width=120, font=self.fonts)
        self.textbox.grid(row=1, column=0, padx=10, pady=(0,10), sticky="ew")

        # ファイル選択ボタン
        self.button_select = customtkinter.CTkButton(master=self, 
            fg_color="transparent", border_width=2, text_color=("gray10", "#DCE4EE"),   # ボタンを白抜きにする
            command=self.button_select_callback, text="ファイル選択", font=self.fonts)
        self.button_select.grid(row=1, column=1, padx=10, pady=(0,10))
        
        # 開くボタン
        self.button_open = customtkinter.CTkButton(master=self, command=self.button_open_callback, text="開く", font=self.fonts)
        self.button_open.grid(row=1, column=2, padx=10, pady=(0,10))

    def button_select_callback(self):
        """
        選択ボタンが押されたときのコールバック。ファイル選択ダイアログを表示する
        """
        # エクスプローラーを表示してファイルを選択する
        file_name = ReadFileFrame.file_read()

        if file_name is not None:
            # ファイルパスをテキストボックスに記入
            self.textbox.delete(0, tk.END)
            self.textbox.insert(0, file_name)

-   def button_open_callback(self):
-       """
-       開くボタンが押されたときのコールバック。暫定機能として、ファイルの中身をprintする
-       """
-       file_name = self.textbox.get()
-       if file_name is not None or len(file_name) != 0:
-           with open(file_name) as f:
-               data = f.read()
-               print(data)
-

+   def button_open_callback(self):
+       """
+       開くボタンが押されたときのコールバック。ファイル名を取得して、親に描画の更新を依頼する
+       """
+       if self.textbox.get() is not None:
+           csv_filepath = self.textbox.get()
+           self.master.update_canvas(csv_filepath)
            
    @staticmethod
    def file_read():
        """
        ファイル選択ダイアログを表示する
        """
        current_dir = os.path.abspath(os.path.dirname(__file__))
        file_path = tk.filedialog.askopenfilename(filetypes=[("csvファイル","*.csv")],initialdir=current_dir)

        if len(file_path) != 0:
            return file_path
        else:
            # ファイル選択がキャンセルされた場合
            return None

# PlotMainFrame と PlotConfigFrameのコードは省略

if __name__ == "__main__":
    app = App()
    app.mainloop()

コードの補足

  • アプリケーションのトップクラス (App) から,2つ目のフレームをインポートするようにしました。

    # 2つ目のフレームの設定
    self.plot_main_frame = PlotMainFrame(master=self, header_name="プロット表示")
    self.plot_main_frame.grid(row=1, column=0, padx=20, pady=(0,10), sticky="nsew")
    

    この2つ目のフレームは、GUIをリサイズしたときに、一緒に縦横両方に拡張してほしいため sticky="nsew" としています。

  • アプリケーションのトップクラス (App) に、2つ目のフレーム (PlotMainFrame) までプロットの描画更新を指示する update_canvas 関数を追加しました。

    def update_canvas(self, csv_filepath=None):
        """
        描画の更新を指令する
        """
        if csv_filepath is not None:
            self.csv_filepath = csv_filepath
        self.plot_main_frame.update(csv_filepath=self.csv_filepath)
    
  • 開くボタンが押されたときのコールバック button_open_callback の中身を、↑で書いた update_canvas 関数へcsvファイルパスを渡す機能に変更しました。

    def button_open_callback(self):
        """
        開くボタンが押されたときのコールバック。ファイル名を取得して、親に描画の更新を依頼する
        """
        if self.textbox.get() is not None:
            csv_filepath = self.textbox.get()
            self.master.update_canvas(csv_filepath)
    

これで GUI が完成しました。実行した結果が冒頭にも貼ったこちらになります。

demo.gif

3. まとめ

3.1. 完成したコード

最終コードはこちらになります。

完成したコード
plot_view_gui.py
import tkinter as tk
import customtkinter
import os
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from plot_control import PlotControl


FONT_TYPE = "meiryo"

class App(customtkinter.CTk):

    def __init__(self):
        super().__init__()

        # メンバー変数の設定
        self.fonts = (FONT_TYPE, 15)
        self.csv_filepath = None

        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        # CustomTkinter のフォームデザイン設定
        customtkinter.set_appearance_mode("dark")  # Modes: system (default), light, dark
        customtkinter.set_default_color_theme("blue")  # Themes: blue (default), dark-blue, green

        # フォームサイズ設定
        self.geometry("1000x600")
        self.title("CSV plot viewer")
        self.minsize(300, 400)

        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(1, weight=1)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(0, weight=1)

        # 1つ目のフレームの設定
        # stickyは拡大したときに広がる方向のこと。nsew で4方角で指定する。
        self.read_file_frame = ReadFileFrame(master=self, header_name="ファイル読み込み")
        self.read_file_frame.grid(row=0, column=0, padx=20, pady=20, sticky="ew")

        # 2つ目のフレームの設定
        self.plot_main_frame = PlotMainFrame(master=self, header_name="プロット表示")
        self.plot_main_frame.grid(row=1, column=0, padx=20, pady=(0,10), sticky="nsew")

    def update_canvas(self, csv_filepath=None):
        """
        描画の更新を指令する
        """
        if csv_filepath is not None:
            self.csv_filepath = csv_filepath
        self.plot_main_frame.update(csv_filepath=self.csv_filepath)


class ReadFileFrame(customtkinter.CTkFrame):
    def __init__(self, *args, header_name="ReadFileFrame", **kwargs):
        super().__init__(*args, **kwargs)
        
        self.fonts = (FONT_TYPE, 15)
        self.header_name = header_name

        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(0, weight=1)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(0, weight=1)

        # フレームのラベルを表示
        self.label = customtkinter.CTkLabel(self, text=self.header_name, font=(FONT_TYPE, 11))
        self.label.grid(row=0, column=0, padx=20, sticky="w")

        # ファイルパスを指定するテキストボックス。これだけ拡大したときに、幅が広がるように設定する。
        self.textbox = customtkinter.CTkEntry(master=self, placeholder_text="CSV ファイルを読み込む", width=120, font=self.fonts)
        self.textbox.grid(row=1, column=0, padx=10, pady=(0,10), sticky="ew")

        # ファイル選択ボタン
        self.button_select = customtkinter.CTkButton(master=self, 
            fg_color="transparent", border_width=2, text_color=("gray10", "#DCE4EE"),   # ボタンを白抜きにする
            command=self.button_select_callback, text="ファイル選択", font=self.fonts)
        self.button_select.grid(row=1, column=1, padx=10, pady=(0,10))
        
        # 開くボタン
        self.button_open = customtkinter.CTkButton(master=self, command=self.button_open_callback, text="開く", font=self.fonts)
        self.button_open.grid(row=1, column=2, padx=10, pady=(0,10))

    def button_select_callback(self):
        """
        選択ボタンが押されたときのコールバック。ファイル選択ダイアログを表示する
        """
        # エクスプローラーを表示してファイルを選択する
        file_name = ReadFileFrame.file_read()

        if file_name is not None:
            # ファイルパスをテキストボックスに記入
            self.textbox.delete(0, tk.END)
            self.textbox.insert(0, file_name)

    def button_open_callback(self):
        """
        開くボタンが押されたときのコールバック。ファイル名を取得して、親に描画の更新を依頼する
        """
        if self.textbox.get() is not None:
            csv_filepath = self.textbox.get()
            self.master.update_canvas(csv_filepath)
            
    @staticmethod
    def file_read():
        """
        ファイル選択ダイアログを表示する
        """
        current_dir = os.path.abspath(os.path.dirname(__file__))
        file_path = tk.filedialog.askopenfilename(filetypes=[("csvファイル","*.csv")],initialdir=current_dir)

        if len(file_path) != 0:
            return file_path
        else:
            # ファイル選択がキャンセルされた場合
            return None


class PlotMainFrame(customtkinter.CTkFrame):
    """
    プロットを表示するメインフレーム
    """
    def __init__(self, *args, header_name="PlotMainFrame", **kwargs):
        super().__init__(*args, **kwargs)
        
        self.fonts = (FONT_TYPE, 15)
        self.header_name = header_name
        # プロット機能をインポート
        self.plot_control = PlotControl()

        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        """
        フォームデザインのセットアップ
        """

        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(0, weight=1)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(1, weight=1)

        # 左側のGUI調整ボタンを ウィジェットとしてインポートする
        self.plot_edit_frame = PlotConfigFrame(master=self, header_name="プロット設定", plot_config=self.plot_control.config)
        self.plot_edit_frame.grid(row=0, column=0, padx=20, pady=20, sticky="ns")

        # プロットをキャンバスに貼り付ける
        self.canvas = FigureCanvasTkAgg(self.plot_control.fig,  master=self)
        self.canvas.get_tk_widget().grid(row=0,column=1, padx=20, pady=20, sticky="nsew")

        # 保存ボタンを置く
        self.button_save = customtkinter.CTkButton(master=self, command=self.button_save_callback, text="保存", font=self.fonts)
        self.button_save.grid(row=0, column=2, padx=10, pady=20, sticky="s")   

    def update(self, csv_filepath=None, config=None):
        """
        プロット描画を更新する
        """
        self.plot_control.replot(csv_filepath, config)
        self.canvas.draw()
    
    def button_save_callback(self):
        """
        保存ボタンが押されたときのコールバック。pngに出力する
        """
        filepath = self.plot_control.save_fig()
        if filepath is not None:
            tk.messagebox.showinfo("確認", f"{filepath} に出力しました。")

class PlotConfigFrame(customtkinter.CTkFrame):
    """
    プロットを線種を調整するサブフレーム
    """
    def __init__(self, *args, header_name="PlotConfigFrame", plot_config=None, **kwargs):
        super().__init__(*args, **kwargs)
        
        self.fonts = (FONT_TYPE, 15)
        self.header_name = header_name
        # plog のコンフィグ設定をコピーする
        self.plot_config = plot_config.copy()
        # フォームのセットアップをする
        self.setup_form()

    def setup_form(self):
        """
        フォームデザインのセットアップ
        """
        # 行方向のマスのレイアウトを設定する。リサイズしたときに一緒に拡大したい行をweight 1に設定。
        self.grid_rowconfigure(0, weight=0)
        # 列方向のマスのレイアウトを設定する
        self.grid_columnconfigure(0, weight=1)

        self.label = customtkinter.CTkLabel(self, text=self.header_name, font=(FONT_TYPE, 11))
        self.label.grid(row=0, column=0, padx=20, sticky="nw")

        # 線の太さを選択する
        self.slider_label = customtkinter.CTkLabel(self, text="ライン幅 2.5", font=(FONT_TYPE, 13))
        self.slider_label.grid(row=1, column=0, padx=20, pady=(20,0), sticky="ew")

        self.slider = customtkinter.CTkSlider(master=self, from_=0.5, to=5, number_of_steps=9, hover=False, width=150, command=self.slider_event)
        self.slider.grid(row=2, column=0, padx=20, pady=(0,20), sticky="ew")

        # 線の種類を選択する
        self.combobox_label = customtkinter.CTkLabel(self, text="線種", font=(FONT_TYPE, 13))
        self.combobox_label.grid(row=3, column=0, padx=20, pady=(20,0), sticky="ew")

        self.combobox = customtkinter.CTkComboBox(master=self, font=self.fonts,
                                     values=["line", "dashed", "line + marker"],
                                     command=self.combobox_callback)
        self.combobox.grid(row=4, column=0, padx=20, pady=(0,20), sticky="ew")

    def slider_event(self, value):
        """
        スライダーで線の太さを変更するときのコールバック
        """
        # マウスでバーを動かすと、何回も更新がかかることがあるので、数値に変化があったか確認する
        old_label = self.slider_label.cget("text")
        new_label = f"ライン幅 {value}"
        if old_label != new_label:
            # 値に変更があったときのみ更新をかける
            self.slider_label.configure(text=new_label)
            self.plot_config["linewidth"] = value
            self.master.update(config=self.plot_config)
    
    def combobox_callback(self,value):
        """
        プルダウンで線種を変更するときのコールバック
        """
        self.plot_config["linetype"] = value
        self.master.update(config=self.plot_config)


if __name__ == "__main__":
    app = App()
    app.mainloop()
plot_control.py
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import os

class PlotControl:
    """
    matplotlib でプロットを行う
    """
    def __init__(self):
        """
        コンストラクタ
        """
        # figの作成
        self.fig = plt.figure()
        # 座標軸の作成
        self.ax = self.fig.add_subplot(1, 1, 1)
        # コンフィグ設定
        self.config = {"linewidth":2, "linetype":"line"}
        self.filepath = None
        self.df = None

    def replot(self, filename=None, config=None):
        """
        プロットを更新する
        """

        # ファイル名が設定されていれば、データを更新する
        if filename is not None:
            # ファイルデータを読み取る
            self.filepath = filename
            self.df = pd.read_csv(filename)
        if self.df is None:
            return

        # コンフィグが設定されていれば、更新する
        if config is not None:
            self.config.update(config)
        
        # 重ねて描画しないように一度消す
        self.ax.clear()

        # plotする
        # plotの線種の種類をGUIから変更できるようにする。
        if self.config["linetype"] == "line + marker":
            self.ax.plot(self.df["time"], self.df["y"], "o-", linewidth=self.config["linewidth"])
        elif self.config["linetype"] == "dashed":
            self.ax.plot(self.df["time"], self.df["y"], "--", linewidth=self.config["linewidth"])
        else:
            self.ax.plot(self.df["time"], self.df["y"], linewidth=self.config["linewidth"])

        self.ax.set_xlabel("time")
        self.ax.set_ylabel("y")

    def save_fig(self, export_path=None):
        """
        figをファイルに保存する
        """
        # ファイル名が設定されていないときは、csvファイル名から拡張子だけを変えたものとする
        if export_path is None and self.filepath is not None:
            file, ext = os.path.splitext(self.filepath)
            export_path = f"{file}.png"

        if export_path is None:
            return
        
        self.fig.savefig(export_path)
        return export_path

if __name__ == "__main__":
    # サンプルcsvを出力する
    filename = "sample_data.csv"
    time = np.linspace(0.0, 3.0)
    y = np.sin(2*np.pi*time)
    out_df = pd.DataFrame(data={"time": time, "y": y})
    out_df.to_csv(filename)

    # 通常のグラフを描画する
    plot_control = PlotControl()
    plot_control.replot(filename)
    plot_control.save_fig("test1.png")

    # 線種を変更する
    config = plot_control.config.copy()
    config["linewidth"] = 5
    plot_control.replot(config=config)
    plot_control.save_fig("test2.png")

3.2. 反省点

 今回、フレームで分けて、順番にGUIを作っていく方法を紹介しました。何も考えずに適当に GUI を作成するとスパゲッティコードになるため、今回のようにフレームで分ける方がいいと思いますが、各フレーム間でどう変数をやり取りするか(今回はcsvファイル名や、プロットの設定などをフレーム間で受け渡しする必要がありました)がモノリシックで作成する場合と比べて難しくなるかなと思います。
 今回のサンプルではコードがあまり複雑にならないように、各関数に update という関数を準備して、それを呼び合う形にしましたが、デザインパターンのObserverパターンを適用するなど、もう少し考えて作成した方がよいかなと思いました。

3.3. 関連記事

以下の記事で、ChatGPT-4 を使ってこのGUIと同じデザインのものがどのくらい簡単に作れるかを試しています。

3.4. 参考

以下、参考にさせていただきました。ありがとうございます。

新規登録して、もっと便利にQiitaを使ってみよう

  1. あなたにマッチした記事をお届けします
  2. 便利な情報をあとで効率的に読み返せます
  3. ダークテーマを利用できます
ログインすると使える機能について

コメント

この記事にコメントはありません。
あなたもコメントしてみませんか :)
新規登録
すでにアカウントを持っている方はログイン

Qiita Advent Calendar 開催中!

Qiita Advent Calendarとは、カレンダーを埋めていく形で記事を投稿する記事投稿イベントです🎅

プレゼントがもらえるカレンダーや、全カレンダー対象のプレゼントも👀

記事をカレンダーに紐づけて、一緒にクリスマスを盛り上げましょう!

92