Windows GUIプログラミング

※サムネイル画像は、生成AIで作成したイメージです


Pythonで業務を少し便利にするツールを作りたいと思ったとき、GUIがあると使い勝手はぐっと良くなります。
本記事では、学習用に用意した独自ツール「pyRenamer」のソースコード解説を通して、下記の習得を目指します。

  • Windows GUIプログラムを作成する方法
  • イベントハンドラの書き方
  • Drag&Drop(以降、D&D)されたファイルやフォルダを処理する方法
  • Windowsのフォルダ選択コモンダイアログを呼び出す方法

この記事は以下のターゲットを対象としています。

★5 Django の開発経験が 3 年以上。
★4 Django の開発経験が 1 年以上。
★3 WEB サイト開発経験あり。これから Django を学習します。
★2 Python 初級者。簡単なプログラムコードが書けます。
★1 プログラミング未経験。

pyRenamerツールの紹介

「複数ファイルの名前変更・コピーを一括で行うためのGUIツール」です。
以下、具体的な使い方について説明します。
入力ファイル一覧指定部(①)に、"hoge\abc.txt"というファイルをドラッグ&ドロップ(以下、D&D)するとテキストエリア欄に"hoge\abc.txt"というテキストが追記されます。
出力ファイル一覧指定部(②)のリセットボタンを押すと、テキストエリアに、①のファイル名のみを引き継いで、"abc.txt"というテキストが表示されます。
ここでは、"abc.txt"を書き換えて"abc_20260116.txt"に変更したとします。
この状態で、

  • リネームボタン(③)を押した場合、①と②の各行を対応付けて、ファイル名のリネームを行います。
    "hoge\abc.txt"が、"hoge\abc_20260116.txt"にリネームされます
  • 複製ボタン(④)を押した場合は、フォルダ選択のコモンダイアログを開き、指定したフォルダ直下に
    ①で指定したファイルを②で指定した名前にリネームして複製します。
    "hoge\abc.txt"が、"{指定フォルダ}\abc_20260116.txt"としてコピーされます (マスタファイルを管理するフォルダから、納品用フォルダを切り出す用途を想定した機能です)

ソースコード(pyRenamer.py)

以下、ソースコード

import os
import shutil
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from tkinterdnd2 import DND_FILES, TkinterDnD


# ============================================================
# ファイルリネーム・コピーツール
#
# 機能概要:
# ・ファイルパスを複数行で入力(D&D対応)
# ・対応する新ファイル名を行単位で指定
# ・一括リネーム/一括コピーを実行
# ============================================================
class RenameTool(TkinterDnD.Tk):

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

        # ウィンドウ基本設定
        self.title("ファイルリネーム・コピーツール")
        self.geometry("1000x560")

        # UI 構築開始
        self._build_ui()

    # ========================================================
    # UI 構築(エントリポイント)
    #
    # UI 構築は以下のフェーズに分割する:
    #   ① レイアウト骨組みの構築
    #   ② ファイル一覧エリアの構築
    #   ③ 操作エリアの構築
    #   ④ 各種イベント・挙動の接続
    # 
    # UI構成ツリー
    # RenameTool(Tk / メインウィンドウ)
    # └─ root_pane(PanedWindow|縦分割:全体レイアウト)
    #    ├─ io_section(PanedWindow|横分割:入出力エリア)
    #    │  ├─ input_frame(Frame|入力側コンテナ)
    #    │  │  ├─ input_header(Frame|ラベル・補助UI)
    #    │  │  │  └─ input_label(Label|「入力ファイル」等)
    #    │  │  ├─ input_line_numbers(Text / Canvas|行番号)
    #    │  │  ├─ input_text(Text|入力ファイル一覧)
    #    │  │  └─ input_scrollbar(Scrollbar|縦スクロール)
    #    │  │
    #    │  └─ output_frame(Frame|出力側コンテナ)
    #    │     ├─ output_header(Frame|ラベル・補助UI)
    #    │     │  └─ output_label(Label|「出力ファイル名」等)
    #    │     ├─ output_line_numbers(Text / Canvas|行番号)
    #    │     ├─ output_text(Text|出力ファイル一覧)
    #    │     └─ output_scrollbar(Scrollbar|縦スクロール)
    #    │
    #    └─ action_section(Frame|操作エリア)
    #       ├─ action_buttons_frame(Frame|ボタン群)
    #       │  ├─ rename_button(Button|リネーム実行)
    #       │  ├─ copy_button(Button|コピー実行)
    #       │  └─ clear_button(Button|クリア)
    #       │
    #       └─ status_frame(Frame|状態表示)
    #          └─ status_label(Label|メッセージ表示)
    # ========================================================
    def _build_ui(self):
        self._build_layout()
        self._build_file_list_area()
        self._build_action_area(self.action_area)
        self._setup_scroll_sync()
        self._bind_mousewheel()
        self._setup_drag_and_drop()

    # ========================================================
    # ① レイアウト骨組み
    #
    # root_pane(縦分割)
    # ├─ file_list_section(①②)
    # └─ action_area(③④)
    # ========================================================
    def _build_layout(self):
        # 画面全体を上下に分割
        self.root_pane = tk.PanedWindow(
            self,
            orient=tk.VERTICAL,
            sashwidth=6,
            sashrelief=tk.RAISED,
            showhandle=True
        )
        self.root_pane.pack(fill=tk.BOTH, expand=True)

        # 上部:ファイル一覧エリア(①②)
        self.file_list_section = ttk.Frame(self.root_pane)
        self.root_pane.add(self.file_list_section, stretch="always")

        # 下部:操作エリア(③④)
        self.action_area = ttk.Frame(self.root_pane)
        self.root_pane.add(self.action_area)

    # ========================================================
    # ② ファイル一覧エリア(①②)
    #
    # 左右に分割可能な PanedWindow + 共通スクロールバー
    # ========================================================
    def _build_file_list_area(self):
        # 左右分割用 PanedWindow
        self.file_list_pane = tk.PanedWindow(
            self.file_list_section,
            orient=tk.HORIZONTAL,
            sashwidth=6,
            sashrelief=tk.RAISED,
            showhandle=True
        )
        self.file_list_pane.pack(fill=tk.BOTH, expand=True)

        # -------- ① 入力ファイル一覧 --------
        self.input_frame = ttk.Frame(self.file_list_pane)
        self.file_list_pane.add(self.input_frame, stretch="always")

        self.input_text, self.input_lines = self._build_text_area(
            self.input_frame,
            title="① 入力ファイル",
            clear_button=True
        )

        # -------- ② 出力ファイル名一覧 --------
        self.output_frame = ttk.Frame(self.file_list_pane)
        self.file_list_pane.add(self.output_frame, stretch="always")

        self.output_text, self.output_lines = self._build_text_area(
            self.output_frame,
            title="② 出力ファイル",
            reset_button=True
        )

        # 共通縦スクロールバー
        self.v_scroll = ttk.Scrollbar(
            self.file_list_section,
            orient=tk.VERTICAL
        )
        self.v_scroll.pack(side=tk.RIGHT, fill=tk.Y)

    # ========================================================
    # 行番号付きテキストエリア生成
    #
    # ・左:行番号(編集不可)
    # ・右:テキスト入力欄
    # ========================================================
    def _build_text_area(
        self,
        parent,
        title,
        clear_button=False,
        reset_button=False
    ):
        # ヘッダ部(タイトル+操作ボタン)
        header = ttk.Frame(parent)
        header.pack(fill=tk.X)

        ttk.Label(header, text=title).pack(side=tk.LEFT)

        if clear_button:
            ttk.Button(
                header,
                text="クリア",
                command=self._clear_input
            ).pack(side=tk.RIGHT, padx=4)

        if reset_button:
            ttk.Button(
                header,
                text="リセット",
                command=self._reset_output
            ).pack(side=tk.RIGHT, padx=4)

        # 本体部
        body = ttk.Frame(parent)
        body.pack(fill=tk.BOTH, expand=True)

        # 行番号欄
        lines = tk.Text(
            body,
            width=4,
            padx=4,
            takefocus=0,
            state="disabled",
            background="#f0f0f0"
        )
        lines.pack(side=tk.LEFT, fill=tk.Y)

        # メインテキスト欄
        text = tk.Text(body, wrap="none")
        text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        # 横スクロールバー
        hbar = ttk.Scrollbar(
            parent,
            orient=tk.HORIZONTAL,
            command=text.xview
        )
        hbar.pack(fill=tk.X)
        text.config(xscrollcommand=hbar.set)

        # 入力変更時に行番号を再生成
        text.bind(
            "<KeyRelease>",
            lambda e: self._update_lines(text, lines)
        )

        return text, lines

    # ========================================================
    # ③④ 操作エリア
    # ========================================================
    def _build_action_area(self, parent):
        wrapper = ttk.Frame(parent)
        wrapper.pack(fill=tk.X, padx=10, pady=10)

        # --- ③ リネーム ---
        rename_area = ttk.Frame(wrapper)
        rename_area.pack(anchor=tk.W, pady=5)

        ttk.Label(
            rename_area,
            text="③ リネーム:①→②の対応で元ファイルを直接改名"
        ).pack(anchor=tk.W)

        tk.Button(
            rename_area,
            text="リネーム",
            bg="#ffcc66",
            command=self._rename
        ).pack(anchor=tk.W, pady=3)

        # --- ④ コピー ---
        copy_area = ttk.Frame(wrapper)
        copy_area.pack(anchor=tk.W, pady=5)

        ttk.Label(
            copy_area,
            text="④ 指定フォルダ直下にコピー:①内容を②名でコピー"
        ).pack(anchor=tk.W)

        tk.Button(
            copy_area,
            text="コピー",
            bg="#99ccff",
            command=self._duplicate
        ).pack(anchor=tk.W, pady=3)

    # ========================================================
    # ④ UI 挙動・イベント設定
    # ========================================================

    # 縦スクロール同期
    def _setup_scroll_sync(self):
        self.v_scroll.config(command=self._yview_all)
        self.input_text.config(yscrollcommand=self.v_scroll.set)
        self.output_text.config(yscrollcommand=self.v_scroll.set)

    # マウスホイール同期
    def _bind_mousewheel(self):
        for w in (
            self.input_text,
            self.output_text,
            self.input_lines,
            self.output_lines,
        ):
            w.bind("<MouseWheel>", self._on_mousewheel)

    # ドラッグ&ドロップ設定
    def _setup_drag_and_drop(self):
        self.input_text.drop_target_register(DND_FILES)
        self.input_text.dnd_bind("<<Drop>>", self._on_drop)

    # ========================================================
    # スクロール制御
    # ========================================================
    def _yview_all(self, *args):
        self.input_text.yview(*args)
        self.output_text.yview(*args)
        self.input_lines.yview(*args)
        self.output_lines.yview(*args)

    def _on_mousewheel(self, event):
        delta = -1 if event.delta > 0 else 1
        self._yview_all("scroll", delta, "units")
        return "break"

    # ========================================================
    # ロジック処理
    # ========================================================
    def _get_lines(self, text):
        return [
            l for l in text.get("1.0", tk.END).splitlines()
            if l.strip()
        ]

    def _validate(self):
        src = self._get_lines(self.input_text)
        dst = self._get_lines(self.output_text)

        if len(src) != len(dst):
            raise ValueError("①と②の行数が一致していません")
        if len(set(src)) != len(src):
            raise ValueError("①に重複パスがあります")
        if len(set(dst)) != len(dst):
            raise ValueError("②の出力名が重複します")

        for p in src:
            if not os.path.isfile(p):
                raise ValueError(f"存在しないファイル: {p}")

        return src, dst

    def _rename(self):
        try:
            src, dst = self._validate()
            for s, d in zip(src, dst):
                os.rename(s, os.path.join(os.path.dirname(s), d))
            messagebox.showinfo("完了", "リネームが完了しました")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _duplicate(self):
        try:
            src, dst = self._validate()
        except Exception as e:
            messagebox.showerror("エラー", str(e))
            return

        folder = filedialog.askdirectory()
        if not folder:
            return

        for s, d in zip(src, dst):
            shutil.copy2(s, os.path.join(folder, d))

        messagebox.showinfo("完了", "コピーが完了しました")

    # ========================================================
    # 補助処理
    # ========================================================
    def _reset_output(self):
        self.output_text.delete("1.0", tk.END)
        for p in self._get_lines(self.input_text):
            self.output_text.insert(
                tk.END,
                os.path.basename(p) + "\n"
            )

    def _clear_input(self):
        self.input_text.delete("1.0", tk.END)
        self.output_text.delete("1.0", tk.END)

    def _update_lines(self, text, lines):
        count = int(text.index("end-1c").split(".")[0])
        lines.config(state="normal")
        lines.delete("1.0", tk.END)

        for i in range(1, count + 1):
            lines.insert(tk.END, f"{i:>3}\n")

        lines.config(state="disabled")

    def _on_drop(self, event):
        paths = self.tk.splitlist(event.data)
        for p in paths:
            if os.path.isdir(p):
                for r, _, fs in os.walk(p):
                    for f in fs:
                        self.input_text.insert(
                            tk.END, os.path.join(r, f) + "\n"
                        )
            else:
                self.input_text.insert(tk.END, p + "\n")


if __name__ == "__main__":
    RenameTool().mainloop()

実行方法

  • Pythonをインストールして、環境変数PATHにpython.exeへのパスを登録します。
  • tkinterdnd2のインストール
    pip install tkinterdnd2
  • コマンドプロンプトなどで、カレントフォルダをpyRenamer.pyのあるフォルダに移動し、下記を実行します。
     python pyRenamer.py

ソースコード説明 1: Windows GUIプログラムを作成する方法

GUIプログラムのポイントを説明していきます。

import tkinter as tk

→tkinterというライブラリを使用しています。
これは、Pythonの標準ライブラリでGUIプログラミングを行うためのライブラリです。   (WindowsだけでなくLinuxでも同じコードで動作します)

class RenameTool(TkinterDnD.Tk):

→TkinterDnD.Tkを継承したクラスRenameToolを定義しています

RenameTool().mainloop()

→RenameTool()により、通常のクラス同様に、インスタンスが生成され、コンストラクタとして__init__()が実行されます。
各種初期化処理を行った後、self._build_ui()の呼び出しで、UI要素を順次作成していきます。
インスタンス作成後は、作成したインスタンスのmainloop()を実行しています。
mainloop()は、内部に無限ループの構造を持っており、イベントが発生していないかを監視し、イベントが発生したら対応したイベントハンドラをコールバックするという処理を延々と繰り返しています。
例えば、ボタンを押したり、D&Dしたり、ウィンドウサイズを広げたりするとイベントが発生します。

ソースコード説明 2: イベントハンドラの書き方

リネームボタンを押した時は、_rename()を呼ぶことで、リネーム処理を行います。

tk.Button(
    rename_area,
    text="リネーム",
    bg="#ffcc66",
    command=self._rename
).pack(anchor=tk.W, pady=3)

→引数commandに関数self._renameの参照を設定しています。
これにより、ボタン押下イベント発生時に、イベントハンドラとしてself._renameが呼ばれるようになります。

ソースコード説明 3: D&Dされたファイルやフォルダを処理する方法

D&Dを実現している方法について説明します

from tkinterdnd2 import DND_FILES, TkinterDnD

→tkinterdnd2というライブラリを使用しています。
これはファイルやフォルダのD&Dを実現するためのライブラリです。

self.input_text.drop_target_register(DND_FILES) 
self.input_text.dnd_bind("<<Drop>>", self._on_drop)

→D&Dの受け取り対象にDND_FILESを指定することで、「複数ファイル(またはフォルダ)」のドロップに対応できるようにしています。
また、ドロップイベントに、self._on_dropをイベントハンドラとして紐づけています

def _on_drop(self, event):
    paths = self.tk.splitlist(event.data)
    for p in paths:
        if os.path.isdir(p):
            for r, _, fs in os.walk(p):
                for f in fs:
                    self.input_text.insert(
                        tk.END, os.path.join(r, f) + "\n"
                    )
        else:
            self.input_text.insert(tk.END, p + "\n")

→_on_dropでは、ドロップされたものが、

  • フォルダであれば、そのフォルダ配下のサブフォルダも再帰的に参照し、見つかったファイルパスを①のテキストエリア末尾に追記します。
  • ファイルであれば、そのファイルパスを①のテキストエリア末尾に追記します。

ソースコード説明 4: Windowsのフォルダ選択コモンダイアログを呼び出す方法

フォルダ選択のコモンダイアログを表示する方法は簡単です。

folder = filedialog.askdirectory()

→フォルダ選択ダイアログを開き、選択結果のフォルダがfolderに格納されます。

まとめ

過去に.NET FrameWork等を使ってのGUIアプリケーションやツールを作成した経験はありましたが、今回Python環境で作成してみて感じたメリットは、開発をスタートするまでの敷居の低さと、動作確認→修正→動作確認のサイクルが短く回せることでした。
デザイナツール(画面要素をビジュアル的に配置し、コードを自動生成するツール)を今回は使用しなかったこともあり、実際に実行してみると期待している画面の動きと食い違うことが、しばしばありましたが、「少し修正して確認」というのが短時間で回せたので、集中力が途切れることなく作業できました。