中級者向け No.35

ファイル圧縮・解凍ツール

ZIP・TAR・GZ形式でファイルを圧縮・解凍するGUIツール。zipfileとtarfileモジュールの活用を学びます。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 30〜90分

1. アプリ概要

ZIP・TAR・GZ形式でファイルを圧縮・解凍するGUIツール。zipfileとtarfileモジュールの活用を学びます。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。

Pythonでは tkinter を使うことで、クロスプラットフォームなGUIアプリを簡単に作成できます。このアプリを通じて、ウィジェットの配置・イベント処理・データ管理など、GUI開発の実践的なスキルを習得できます。

ソースコードは完全な動作状態で提供しており、コピーしてそのまま実行できます。まずは実行して動作を確認し、その後コードを読んで仕組みを理解していきましょう。カスタマイズセクションでは機能拡張のアイデアも紹介しています。

GUIアプリ開発は、プログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。アプリを作ることで、変数・関数・クラス・イベント処理など、プログラミングの重要な概念が自然と身についていきます。このアプリをきっかけに、オリジナルアプリの開発にも挑戦してみてください。

2. 機能一覧

  • ファイル圧縮・解凍ツールのメイン機能
  • 直感的なGUIインターフェース
  • 入力値のバリデーション
  • エラーハンドリング
  • 結果の見やすい表示
  • キーボードショートカット対応

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows・Mac・Linux すべて対応

以下の環境で動作確認しています。

  • Python 3.10 以上
  • OS: Windows 10/11・macOS 12+・Ubuntu 20.04+

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

app35.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import zipfile
import tarfile
import os
import threading
from datetime import datetime


class App35:
    """ファイル圧縮・解凍ツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("ファイル圧縮・解凍ツール")
        self.root.geometry("860x600")
        self.root.configure(bg="#f8f9fc")
        self._files_to_compress = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#01579b", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🗜️ ファイル圧縮・解凍ツール",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#01579b", fg="white").pack(side=tk.LEFT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 圧縮タブ
        compress_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(compress_tab, text="📦 圧縮")
        self._build_compress_tab(compress_tab)

        # 解凍タブ
        extract_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(extract_tab, text="📂 解凍")
        self._build_extract_tab(extract_tab)

        # アーカイブ閲覧タブ
        view_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(view_tab, text="🔍 内容確認")
        self._build_view_tab(view_tab)

        self.status_var = tk.StringVar(value="ファイルを選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── 圧縮タブ ──────────────────────────────────────────────────

    def _build_compress_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル/フォルダ追加
        top_f = tk.Frame(parent, bg="#f8f9fc")
        top_f.pack(fill=tk.X, padx=8, pady=6)
        ttk.Button(top_f, text="➕ ファイルを追加",
                   command=self._add_files_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="📂 フォルダを追加",
                   command=self._add_folder_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="🗑 リストをクリア",
                   command=self._clear_compress_list).pack(side=tk.LEFT, padx=4)

        # ファイルリスト
        list_f = ttk.LabelFrame(parent, text="圧縮するファイル/フォルダ", padding=4)
        list_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.compress_listbox = tk.Listbox(list_f, font=("Arial", 10), height=10)
        sb = ttk.Scrollbar(list_f, command=self.compress_listbox.yview)
        self.compress_listbox.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.compress_listbox.pack(fill=tk.BOTH, expand=True)

        # 設定
        setting_f = ttk.LabelFrame(parent, text="圧縮設定", padding=8)
        setting_f.pack(fill=tk.X, padx=8, pady=4)
        row1 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row1.pack(fill=tk.X, pady=2)

        tk.Label(row1, text="形式:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT)
        self.compress_fmt_var = tk.StringVar(value="ZIP")
        ttk.Combobox(row1, textvariable=self.compress_fmt_var,
                     values=["ZIP", "TAR", "TAR.GZ", "TAR.BZ2"],
                     state="readonly", width=10).pack(side=tk.LEFT, padx=6)

        tk.Label(row1, text="圧縮レベル:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.compress_level_var = tk.IntVar(value=6)
        ttk.Spinbox(row1, from_=1, to=9,
                    textvariable=self.compress_level_var, width=4).pack(side=tk.LEFT, padx=4)

        row2 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row2.pack(fill=tk.X, pady=4)
        tk.Label(row2, text="出力先:", **lbl_s,
                 bg=row2.cget("bg")).pack(side=tk.LEFT)
        self.compress_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.compress_out_var,
                  width=40).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂",
                   command=self._browse_compress_out).pack(side=tk.LEFT)

        ttk.Button(parent, text="▶ 圧縮実行",
                   command=self._compress).pack(padx=8, pady=6)

        self.compress_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.compress_progress.pack(fill=tk.X, padx=8, pady=2)

    def _add_files_compress(self):
        paths = filedialog.askopenfilenames()
        for p in paths:
            if p not in self._files_to_compress:
                self._files_to_compress.append(p)
                self.compress_listbox.insert(tk.END, p)
        self._suggest_output()

    def _add_folder_compress(self):
        path = filedialog.askdirectory()
        if path and path not in self._files_to_compress:
            self._files_to_compress.append(path)
            self.compress_listbox.insert(tk.END, path)
        self._suggest_output()

    def _clear_compress_list(self):
        self._files_to_compress.clear()
        self.compress_listbox.delete(0, tk.END)

    def _suggest_output(self):
        if self._files_to_compress and not self.compress_out_var.get():
            first = self._files_to_compress[0]
            base = os.path.splitext(os.path.basename(first))[0]
            folder = os.path.dirname(first)
            fmt = self.compress_fmt_var.get().lower().replace(".", "")
            ext = ".zip" if fmt == "zip" else ".tar" if fmt == "tar" else \
                  ".tar.gz" if fmt == "targz" else ".tar.bz2"
            self.compress_out_var.set(os.path.join(folder, base + ext))

    def _browse_compress_out(self):
        fmt = self.compress_fmt_var.get()
        ft = [("ZIP", "*.zip")] if fmt == "ZIP" else \
             [("TAR", "*.tar"), ("TAR.GZ", "*.tar.gz"), ("TAR.BZ2", "*.tar.bz2")]
        path = filedialog.asksaveasfilename(filetypes=ft + [("すべて", "*.*")])
        if path:
            self.compress_out_var.set(path)

    def _compress(self):
        if not self._files_to_compress:
            messagebox.showwarning("警告", "ファイルを追加してください")
            return
        out = self.compress_out_var.get().strip()
        if not out:
            messagebox.showwarning("警告", "出力先を指定してください")
            return
        self.compress_progress.start(10)
        threading.Thread(target=self._do_compress,
                         args=(list(self._files_to_compress), out),
                         daemon=True).start()

    def _do_compress(self, files, out):
        fmt = self.compress_fmt_var.get()
        level = self.compress_level_var.get()
        try:
            if fmt == "ZIP":
                with zipfile.ZipFile(out, "w",
                                     compression=zipfile.ZIP_DEFLATED,
                                     compresslevel=level) as zf:
                    for path in files:
                        if os.path.isdir(path):
                            for dirpath, _, filenames in os.walk(path):
                                for fn in filenames:
                                    fp = os.path.join(dirpath, fn)
                                    arcname = os.path.relpath(fp, os.path.dirname(path))
                                    zf.write(fp, arcname)
                        else:
                            zf.write(path, os.path.basename(path))
            else:
                mode_map = {"TAR": "w", "TAR.GZ": "w:gz", "TAR.BZ2": "w:bz2"}
                mode = mode_map[fmt]
                with tarfile.open(out, mode) as tf:
                    for path in files:
                        tf.add(path, arcname=os.path.basename(path))

            size_kb = os.path.getsize(out) // 1024
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, self.status_var.set,
                            f"圧縮完了: {out} ({size_kb}KB)")
            self.root.after(0, messagebox.showinfo, "完了",
                            f"圧縮完了!\n{out}\n({size_kb}KB)")
        except Exception as e:
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    # ── 解凍タブ ──────────────────────────────────────────────────

    def _build_extract_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル選択
        row1 = tk.Frame(parent, bg="#f8f9fc")
        row1.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row1, text="アーカイブ:", **lbl_s).pack(side=tk.LEFT)
        self.extract_file_var = tk.StringVar()
        ttk.Entry(row1, textvariable=self.extract_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row1, text="📂 参照",
                   command=self._browse_archive).pack(side=tk.LEFT)

        # 出力先
        row2 = tk.Frame(parent, bg="#f8f9fc")
        row2.pack(fill=tk.X, padx=8, pady=4)
        tk.Label(row2, text="解凍先:", **lbl_s).pack(side=tk.LEFT)
        self.extract_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.extract_out_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂 参照",
                   command=lambda: self.extract_out_var.set(
                       filedialog.askdirectory() or self.extract_out_var.get())
                   ).pack(side=tk.LEFT)

        # オプション
        opt_f = ttk.LabelFrame(parent, text="オプション", padding=6)
        opt_f.pack(fill=tk.X, padx=8, pady=4)
        self.extract_subdir_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opt_f, text="アーカイブ名のサブフォルダに解凍",
                         variable=self.extract_subdir_var).pack(anchor="w")
        self.extract_overwrite_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt_f, text="既存ファイルを上書き",
                         variable=self.extract_overwrite_var).pack(anchor="w")

        ttk.Button(parent, text="▶ 解凍実行",
                   command=self._extract).pack(padx=8, pady=6)

        self.extract_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.extract_progress.pack(fill=tk.X, padx=8, pady=2)

        # ログ
        log_f = ttk.LabelFrame(parent, text="解凍ログ", padding=4)
        log_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.extract_log = tk.Text(log_f, height=8, font=("Courier New", 10),
                                    state=tk.DISABLED, bg="#fafafa")
        self.extract_log.pack(fill=tk.BOTH, expand=True)

    def _browse_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.extract_file_var.set(path)
            if not self.extract_out_var.get():
                self.extract_out_var.set(os.path.dirname(path))

    def _extract(self):
        archive = self.extract_file_var.get().strip()
        if not archive or not os.path.exists(archive):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        out = self.extract_out_var.get().strip() or os.path.dirname(archive)
        if self.extract_subdir_var.get():
            base = os.path.splitext(os.path.basename(archive))[0]
            if base.endswith(".tar"):
                base = base[:-4]
            out = os.path.join(out, base)
        os.makedirs(out, exist_ok=True)
        self.extract_progress.start(10)
        threading.Thread(target=self._do_extract,
                         args=(archive, out), daemon=True).start()

    def _do_extract(self, archive, out):
        try:
            extracted = 0
            if zipfile.is_zipfile(archive):
                with zipfile.ZipFile(archive, "r") as zf:
                    members = zf.namelist()
                    for m in members:
                        zf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m}")
            elif tarfile.is_tarfile(archive):
                with tarfile.open(archive) as tf:
                    members = tf.getmembers()
                    for m in members:
                        tf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m.name}")
            else:
                raise ValueError("サポートされていない形式です")

            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, self._log_extract,
                            f"\n✓ 解凍完了: {extracted} ファイル → {out}")
            self.root.after(0, self.status_var.set,
                            f"解凍完了: {extracted} ファイル")
        except Exception as e:
            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    def _log_extract(self, text):
        self.extract_log.config(state=tk.NORMAL)
        self.extract_log.insert(tk.END, text + "\n")
        self.extract_log.see(tk.END)
        self.extract_log.config(state=tk.DISABLED)

    # ── 内容確認タブ ──────────────────────────────────────────────

    def _build_view_tab(self, parent):
        row = tk.Frame(parent, bg="#f8f9fc")
        row.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row, text="アーカイブ:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.view_file_var = tk.StringVar()
        ttk.Entry(row, textvariable=self.view_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row, text="📂 参照",
                   command=self._browse_view_archive).pack(side=tk.LEFT)
        ttk.Button(row, text="🔍 内容確認",
                   command=self._view_archive).pack(side=tk.LEFT, padx=4)

        cols = ("name", "size", "compressed", "type", "date")
        self.view_tree = ttk.Treeview(parent, columns=cols, show="headings")
        for c, h, w in [("name", "ファイル名", 280), ("size", "元サイズ", 80),
                         ("compressed", "圧縮後", 80), ("type", "種別", 60),
                         ("date", "更新日", 130)]:
            self.view_tree.heading(c, text=h)
            self.view_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(parent, command=self.view_tree.yview)
        self.view_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.view_tree.pack(fill=tk.BOTH, expand=True, padx=8)

        self.view_info_var = tk.StringVar()
        tk.Label(parent, textvariable=self.view_info_var, bg="#f8f9fc",
                 font=("Arial", 9), fg="#555").pack(anchor="w", padx=8, pady=2)

    def _browse_view_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.view_file_var.set(path)
            self._view_archive()

    def _view_archive(self):
        path = self.view_file_var.get().strip()
        if not path or not os.path.exists(path):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        self.view_tree.delete(*self.view_tree.get_children())
        total_orig = 0
        total_comp = 0
        count = 0
        try:
            if zipfile.is_zipfile(path):
                with zipfile.ZipFile(path) as zf:
                    for info in zf.infolist():
                        kind = "📁" if info.is_dir() else "📄"
                        date_str = f"{info.date_time[0]}/{info.date_time[1]:02d}/{info.date_time[2]:02d}"
                        self.view_tree.insert(
                            "", "end",
                            values=(info.filename,
                                    self._fmt_size(info.file_size),
                                    self._fmt_size(info.compress_size),
                                    kind, date_str))
                        total_orig += info.file_size
                        total_comp += info.compress_size
                        count += 1
            elif tarfile.is_tarfile(path):
                with tarfile.open(path) as tf:
                    for m in tf.getmembers():
                        kind = "📁" if m.isdir() else "📄"
                        dt = datetime.fromtimestamp(m.mtime).strftime(
                            "%Y/%m/%d") if m.mtime else ""
                        self.view_tree.insert(
                            "", "end",
                            values=(m.name, self._fmt_size(m.size),
                                    "—", kind, dt))
                        total_orig += m.size
                        count += 1
            else:
                messagebox.showerror("エラー", "サポートされていない形式です")
                return

            ratio = (1 - total_comp / total_orig) * 100 if total_orig else 0
            self.view_info_var.set(
                f"合計: {count} アイテム  |  "
                f"元サイズ: {self._fmt_size(total_orig)}  |  "
                f"圧縮後: {self._fmt_size(total_comp)}  |  "
                f"圧縮率: {ratio:.1f}%")
            self.status_var.set(f"確認完了: {count} アイテム")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _fmt_size(self, size):
        for unit in ["B", "KB", "MB", "GB"]:
            if size < 1024:
                return f"{size:.0f}{unit}"
            size /= 1024
        return f"{size:.1f}TB"


if __name__ == "__main__":
    root = tk.Tk()
    app = App35(root)
    root.mainloop()

5. コード解説

ファイル圧縮・解凍ツールのコードを詳しく解説します。クラスベースの設計で各機能を整理して実装しています。

クラス設計とコンストラクタ

App35クラスにアプリの全機能をまとめています。__init__メソッドでウィンドウの基本設定を行い、_build_ui()でUI構築、process()でメイン処理を担当します。この分離により、各メソッドの責任が明確になりコードが読みやすくなります。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import zipfile
import tarfile
import os
import threading
from datetime import datetime


class App35:
    """ファイル圧縮・解凍ツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("ファイル圧縮・解凍ツール")
        self.root.geometry("860x600")
        self.root.configure(bg="#f8f9fc")
        self._files_to_compress = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#01579b", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🗜️ ファイル圧縮・解凍ツール",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#01579b", fg="white").pack(side=tk.LEFT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 圧縮タブ
        compress_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(compress_tab, text="📦 圧縮")
        self._build_compress_tab(compress_tab)

        # 解凍タブ
        extract_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(extract_tab, text="📂 解凍")
        self._build_extract_tab(extract_tab)

        # アーカイブ閲覧タブ
        view_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(view_tab, text="🔍 内容確認")
        self._build_view_tab(view_tab)

        self.status_var = tk.StringVar(value="ファイルを選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── 圧縮タブ ──────────────────────────────────────────────────

    def _build_compress_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル/フォルダ追加
        top_f = tk.Frame(parent, bg="#f8f9fc")
        top_f.pack(fill=tk.X, padx=8, pady=6)
        ttk.Button(top_f, text="➕ ファイルを追加",
                   command=self._add_files_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="📂 フォルダを追加",
                   command=self._add_folder_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="🗑 リストをクリア",
                   command=self._clear_compress_list).pack(side=tk.LEFT, padx=4)

        # ファイルリスト
        list_f = ttk.LabelFrame(parent, text="圧縮するファイル/フォルダ", padding=4)
        list_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.compress_listbox = tk.Listbox(list_f, font=("Arial", 10), height=10)
        sb = ttk.Scrollbar(list_f, command=self.compress_listbox.yview)
        self.compress_listbox.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.compress_listbox.pack(fill=tk.BOTH, expand=True)

        # 設定
        setting_f = ttk.LabelFrame(parent, text="圧縮設定", padding=8)
        setting_f.pack(fill=tk.X, padx=8, pady=4)
        row1 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row1.pack(fill=tk.X, pady=2)

        tk.Label(row1, text="形式:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT)
        self.compress_fmt_var = tk.StringVar(value="ZIP")
        ttk.Combobox(row1, textvariable=self.compress_fmt_var,
                     values=["ZIP", "TAR", "TAR.GZ", "TAR.BZ2"],
                     state="readonly", width=10).pack(side=tk.LEFT, padx=6)

        tk.Label(row1, text="圧縮レベル:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.compress_level_var = tk.IntVar(value=6)
        ttk.Spinbox(row1, from_=1, to=9,
                    textvariable=self.compress_level_var, width=4).pack(side=tk.LEFT, padx=4)

        row2 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row2.pack(fill=tk.X, pady=4)
        tk.Label(row2, text="出力先:", **lbl_s,
                 bg=row2.cget("bg")).pack(side=tk.LEFT)
        self.compress_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.compress_out_var,
                  width=40).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂",
                   command=self._browse_compress_out).pack(side=tk.LEFT)

        ttk.Button(parent, text="▶ 圧縮実行",
                   command=self._compress).pack(padx=8, pady=6)

        self.compress_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.compress_progress.pack(fill=tk.X, padx=8, pady=2)

    def _add_files_compress(self):
        paths = filedialog.askopenfilenames()
        for p in paths:
            if p not in self._files_to_compress:
                self._files_to_compress.append(p)
                self.compress_listbox.insert(tk.END, p)
        self._suggest_output()

    def _add_folder_compress(self):
        path = filedialog.askdirectory()
        if path and path not in self._files_to_compress:
            self._files_to_compress.append(path)
            self.compress_listbox.insert(tk.END, path)
        self._suggest_output()

    def _clear_compress_list(self):
        self._files_to_compress.clear()
        self.compress_listbox.delete(0, tk.END)

    def _suggest_output(self):
        if self._files_to_compress and not self.compress_out_var.get():
            first = self._files_to_compress[0]
            base = os.path.splitext(os.path.basename(first))[0]
            folder = os.path.dirname(first)
            fmt = self.compress_fmt_var.get().lower().replace(".", "")
            ext = ".zip" if fmt == "zip" else ".tar" if fmt == "tar" else \
                  ".tar.gz" if fmt == "targz" else ".tar.bz2"
            self.compress_out_var.set(os.path.join(folder, base + ext))

    def _browse_compress_out(self):
        fmt = self.compress_fmt_var.get()
        ft = [("ZIP", "*.zip")] if fmt == "ZIP" else \
             [("TAR", "*.tar"), ("TAR.GZ", "*.tar.gz"), ("TAR.BZ2", "*.tar.bz2")]
        path = filedialog.asksaveasfilename(filetypes=ft + [("すべて", "*.*")])
        if path:
            self.compress_out_var.set(path)

    def _compress(self):
        if not self._files_to_compress:
            messagebox.showwarning("警告", "ファイルを追加してください")
            return
        out = self.compress_out_var.get().strip()
        if not out:
            messagebox.showwarning("警告", "出力先を指定してください")
            return
        self.compress_progress.start(10)
        threading.Thread(target=self._do_compress,
                         args=(list(self._files_to_compress), out),
                         daemon=True).start()

    def _do_compress(self, files, out):
        fmt = self.compress_fmt_var.get()
        level = self.compress_level_var.get()
        try:
            if fmt == "ZIP":
                with zipfile.ZipFile(out, "w",
                                     compression=zipfile.ZIP_DEFLATED,
                                     compresslevel=level) as zf:
                    for path in files:
                        if os.path.isdir(path):
                            for dirpath, _, filenames in os.walk(path):
                                for fn in filenames:
                                    fp = os.path.join(dirpath, fn)
                                    arcname = os.path.relpath(fp, os.path.dirname(path))
                                    zf.write(fp, arcname)
                        else:
                            zf.write(path, os.path.basename(path))
            else:
                mode_map = {"TAR": "w", "TAR.GZ": "w:gz", "TAR.BZ2": "w:bz2"}
                mode = mode_map[fmt]
                with tarfile.open(out, mode) as tf:
                    for path in files:
                        tf.add(path, arcname=os.path.basename(path))

            size_kb = os.path.getsize(out) // 1024
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, self.status_var.set,
                            f"圧縮完了: {out} ({size_kb}KB)")
            self.root.after(0, messagebox.showinfo, "完了",
                            f"圧縮完了!\n{out}\n({size_kb}KB)")
        except Exception as e:
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    # ── 解凍タブ ──────────────────────────────────────────────────

    def _build_extract_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル選択
        row1 = tk.Frame(parent, bg="#f8f9fc")
        row1.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row1, text="アーカイブ:", **lbl_s).pack(side=tk.LEFT)
        self.extract_file_var = tk.StringVar()
        ttk.Entry(row1, textvariable=self.extract_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row1, text="📂 参照",
                   command=self._browse_archive).pack(side=tk.LEFT)

        # 出力先
        row2 = tk.Frame(parent, bg="#f8f9fc")
        row2.pack(fill=tk.X, padx=8, pady=4)
        tk.Label(row2, text="解凍先:", **lbl_s).pack(side=tk.LEFT)
        self.extract_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.extract_out_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂 参照",
                   command=lambda: self.extract_out_var.set(
                       filedialog.askdirectory() or self.extract_out_var.get())
                   ).pack(side=tk.LEFT)

        # オプション
        opt_f = ttk.LabelFrame(parent, text="オプション", padding=6)
        opt_f.pack(fill=tk.X, padx=8, pady=4)
        self.extract_subdir_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opt_f, text="アーカイブ名のサブフォルダに解凍",
                         variable=self.extract_subdir_var).pack(anchor="w")
        self.extract_overwrite_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt_f, text="既存ファイルを上書き",
                         variable=self.extract_overwrite_var).pack(anchor="w")

        ttk.Button(parent, text="▶ 解凍実行",
                   command=self._extract).pack(padx=8, pady=6)

        self.extract_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.extract_progress.pack(fill=tk.X, padx=8, pady=2)

        # ログ
        log_f = ttk.LabelFrame(parent, text="解凍ログ", padding=4)
        log_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.extract_log = tk.Text(log_f, height=8, font=("Courier New", 10),
                                    state=tk.DISABLED, bg="#fafafa")
        self.extract_log.pack(fill=tk.BOTH, expand=True)

    def _browse_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.extract_file_var.set(path)
            if not self.extract_out_var.get():
                self.extract_out_var.set(os.path.dirname(path))

    def _extract(self):
        archive = self.extract_file_var.get().strip()
        if not archive or not os.path.exists(archive):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        out = self.extract_out_var.get().strip() or os.path.dirname(archive)
        if self.extract_subdir_var.get():
            base = os.path.splitext(os.path.basename(archive))[0]
            if base.endswith(".tar"):
                base = base[:-4]
            out = os.path.join(out, base)
        os.makedirs(out, exist_ok=True)
        self.extract_progress.start(10)
        threading.Thread(target=self._do_extract,
                         args=(archive, out), daemon=True).start()

    def _do_extract(self, archive, out):
        try:
            extracted = 0
            if zipfile.is_zipfile(archive):
                with zipfile.ZipFile(archive, "r") as zf:
                    members = zf.namelist()
                    for m in members:
                        zf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m}")
            elif tarfile.is_tarfile(archive):
                with tarfile.open(archive) as tf:
                    members = tf.getmembers()
                    for m in members:
                        tf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m.name}")
            else:
                raise ValueError("サポートされていない形式です")

            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, self._log_extract,
                            f"\n✓ 解凍完了: {extracted} ファイル → {out}")
            self.root.after(0, self.status_var.set,
                            f"解凍完了: {extracted} ファイル")
        except Exception as e:
            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    def _log_extract(self, text):
        self.extract_log.config(state=tk.NORMAL)
        self.extract_log.insert(tk.END, text + "\n")
        self.extract_log.see(tk.END)
        self.extract_log.config(state=tk.DISABLED)

    # ── 内容確認タブ ──────────────────────────────────────────────

    def _build_view_tab(self, parent):
        row = tk.Frame(parent, bg="#f8f9fc")
        row.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row, text="アーカイブ:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.view_file_var = tk.StringVar()
        ttk.Entry(row, textvariable=self.view_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row, text="📂 参照",
                   command=self._browse_view_archive).pack(side=tk.LEFT)
        ttk.Button(row, text="🔍 内容確認",
                   command=self._view_archive).pack(side=tk.LEFT, padx=4)

        cols = ("name", "size", "compressed", "type", "date")
        self.view_tree = ttk.Treeview(parent, columns=cols, show="headings")
        for c, h, w in [("name", "ファイル名", 280), ("size", "元サイズ", 80),
                         ("compressed", "圧縮後", 80), ("type", "種別", 60),
                         ("date", "更新日", 130)]:
            self.view_tree.heading(c, text=h)
            self.view_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(parent, command=self.view_tree.yview)
        self.view_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.view_tree.pack(fill=tk.BOTH, expand=True, padx=8)

        self.view_info_var = tk.StringVar()
        tk.Label(parent, textvariable=self.view_info_var, bg="#f8f9fc",
                 font=("Arial", 9), fg="#555").pack(anchor="w", padx=8, pady=2)

    def _browse_view_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.view_file_var.set(path)
            self._view_archive()

    def _view_archive(self):
        path = self.view_file_var.get().strip()
        if not path or not os.path.exists(path):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        self.view_tree.delete(*self.view_tree.get_children())
        total_orig = 0
        total_comp = 0
        count = 0
        try:
            if zipfile.is_zipfile(path):
                with zipfile.ZipFile(path) as zf:
                    for info in zf.infolist():
                        kind = "📁" if info.is_dir() else "📄"
                        date_str = f"{info.date_time[0]}/{info.date_time[1]:02d}/{info.date_time[2]:02d}"
                        self.view_tree.insert(
                            "", "end",
                            values=(info.filename,
                                    self._fmt_size(info.file_size),
                                    self._fmt_size(info.compress_size),
                                    kind, date_str))
                        total_orig += info.file_size
                        total_comp += info.compress_size
                        count += 1
            elif tarfile.is_tarfile(path):
                with tarfile.open(path) as tf:
                    for m in tf.getmembers():
                        kind = "📁" if m.isdir() else "📄"
                        dt = datetime.fromtimestamp(m.mtime).strftime(
                            "%Y/%m/%d") if m.mtime else ""
                        self.view_tree.insert(
                            "", "end",
                            values=(m.name, self._fmt_size(m.size),
                                    "—", kind, dt))
                        total_orig += m.size
                        count += 1
            else:
                messagebox.showerror("エラー", "サポートされていない形式です")
                return

            ratio = (1 - total_comp / total_orig) * 100 if total_orig else 0
            self.view_info_var.set(
                f"合計: {count} アイテム  |  "
                f"元サイズ: {self._fmt_size(total_orig)}  |  "
                f"圧縮後: {self._fmt_size(total_comp)}  |  "
                f"圧縮率: {ratio:.1f}%")
            self.status_var.set(f"確認完了: {count} アイテム")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _fmt_size(self, size):
        for unit in ["B", "KB", "MB", "GB"]:
            if size < 1024:
                return f"{size:.0f}{unit}"
            size /= 1024
        return f"{size:.1f}TB"


if __name__ == "__main__":
    root = tk.Tk()
    app = App35(root)
    root.mainloop()

LabelFrameによるセクション分け

ttk.LabelFrame を使うことで、入力エリアと結果エリアを視覚的に分けられます。padding引数でフレーム内の余白を設定し、見やすいレイアウトを実現しています。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import zipfile
import tarfile
import os
import threading
from datetime import datetime


class App35:
    """ファイル圧縮・解凍ツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("ファイル圧縮・解凍ツール")
        self.root.geometry("860x600")
        self.root.configure(bg="#f8f9fc")
        self._files_to_compress = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#01579b", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🗜️ ファイル圧縮・解凍ツール",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#01579b", fg="white").pack(side=tk.LEFT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 圧縮タブ
        compress_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(compress_tab, text="📦 圧縮")
        self._build_compress_tab(compress_tab)

        # 解凍タブ
        extract_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(extract_tab, text="📂 解凍")
        self._build_extract_tab(extract_tab)

        # アーカイブ閲覧タブ
        view_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(view_tab, text="🔍 内容確認")
        self._build_view_tab(view_tab)

        self.status_var = tk.StringVar(value="ファイルを選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── 圧縮タブ ──────────────────────────────────────────────────

    def _build_compress_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル/フォルダ追加
        top_f = tk.Frame(parent, bg="#f8f9fc")
        top_f.pack(fill=tk.X, padx=8, pady=6)
        ttk.Button(top_f, text="➕ ファイルを追加",
                   command=self._add_files_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="📂 フォルダを追加",
                   command=self._add_folder_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="🗑 リストをクリア",
                   command=self._clear_compress_list).pack(side=tk.LEFT, padx=4)

        # ファイルリスト
        list_f = ttk.LabelFrame(parent, text="圧縮するファイル/フォルダ", padding=4)
        list_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.compress_listbox = tk.Listbox(list_f, font=("Arial", 10), height=10)
        sb = ttk.Scrollbar(list_f, command=self.compress_listbox.yview)
        self.compress_listbox.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.compress_listbox.pack(fill=tk.BOTH, expand=True)

        # 設定
        setting_f = ttk.LabelFrame(parent, text="圧縮設定", padding=8)
        setting_f.pack(fill=tk.X, padx=8, pady=4)
        row1 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row1.pack(fill=tk.X, pady=2)

        tk.Label(row1, text="形式:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT)
        self.compress_fmt_var = tk.StringVar(value="ZIP")
        ttk.Combobox(row1, textvariable=self.compress_fmt_var,
                     values=["ZIP", "TAR", "TAR.GZ", "TAR.BZ2"],
                     state="readonly", width=10).pack(side=tk.LEFT, padx=6)

        tk.Label(row1, text="圧縮レベル:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.compress_level_var = tk.IntVar(value=6)
        ttk.Spinbox(row1, from_=1, to=9,
                    textvariable=self.compress_level_var, width=4).pack(side=tk.LEFT, padx=4)

        row2 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row2.pack(fill=tk.X, pady=4)
        tk.Label(row2, text="出力先:", **lbl_s,
                 bg=row2.cget("bg")).pack(side=tk.LEFT)
        self.compress_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.compress_out_var,
                  width=40).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂",
                   command=self._browse_compress_out).pack(side=tk.LEFT)

        ttk.Button(parent, text="▶ 圧縮実行",
                   command=self._compress).pack(padx=8, pady=6)

        self.compress_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.compress_progress.pack(fill=tk.X, padx=8, pady=2)

    def _add_files_compress(self):
        paths = filedialog.askopenfilenames()
        for p in paths:
            if p not in self._files_to_compress:
                self._files_to_compress.append(p)
                self.compress_listbox.insert(tk.END, p)
        self._suggest_output()

    def _add_folder_compress(self):
        path = filedialog.askdirectory()
        if path and path not in self._files_to_compress:
            self._files_to_compress.append(path)
            self.compress_listbox.insert(tk.END, path)
        self._suggest_output()

    def _clear_compress_list(self):
        self._files_to_compress.clear()
        self.compress_listbox.delete(0, tk.END)

    def _suggest_output(self):
        if self._files_to_compress and not self.compress_out_var.get():
            first = self._files_to_compress[0]
            base = os.path.splitext(os.path.basename(first))[0]
            folder = os.path.dirname(first)
            fmt = self.compress_fmt_var.get().lower().replace(".", "")
            ext = ".zip" if fmt == "zip" else ".tar" if fmt == "tar" else \
                  ".tar.gz" if fmt == "targz" else ".tar.bz2"
            self.compress_out_var.set(os.path.join(folder, base + ext))

    def _browse_compress_out(self):
        fmt = self.compress_fmt_var.get()
        ft = [("ZIP", "*.zip")] if fmt == "ZIP" else \
             [("TAR", "*.tar"), ("TAR.GZ", "*.tar.gz"), ("TAR.BZ2", "*.tar.bz2")]
        path = filedialog.asksaveasfilename(filetypes=ft + [("すべて", "*.*")])
        if path:
            self.compress_out_var.set(path)

    def _compress(self):
        if not self._files_to_compress:
            messagebox.showwarning("警告", "ファイルを追加してください")
            return
        out = self.compress_out_var.get().strip()
        if not out:
            messagebox.showwarning("警告", "出力先を指定してください")
            return
        self.compress_progress.start(10)
        threading.Thread(target=self._do_compress,
                         args=(list(self._files_to_compress), out),
                         daemon=True).start()

    def _do_compress(self, files, out):
        fmt = self.compress_fmt_var.get()
        level = self.compress_level_var.get()
        try:
            if fmt == "ZIP":
                with zipfile.ZipFile(out, "w",
                                     compression=zipfile.ZIP_DEFLATED,
                                     compresslevel=level) as zf:
                    for path in files:
                        if os.path.isdir(path):
                            for dirpath, _, filenames in os.walk(path):
                                for fn in filenames:
                                    fp = os.path.join(dirpath, fn)
                                    arcname = os.path.relpath(fp, os.path.dirname(path))
                                    zf.write(fp, arcname)
                        else:
                            zf.write(path, os.path.basename(path))
            else:
                mode_map = {"TAR": "w", "TAR.GZ": "w:gz", "TAR.BZ2": "w:bz2"}
                mode = mode_map[fmt]
                with tarfile.open(out, mode) as tf:
                    for path in files:
                        tf.add(path, arcname=os.path.basename(path))

            size_kb = os.path.getsize(out) // 1024
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, self.status_var.set,
                            f"圧縮完了: {out} ({size_kb}KB)")
            self.root.after(0, messagebox.showinfo, "完了",
                            f"圧縮完了!\n{out}\n({size_kb}KB)")
        except Exception as e:
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    # ── 解凍タブ ──────────────────────────────────────────────────

    def _build_extract_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル選択
        row1 = tk.Frame(parent, bg="#f8f9fc")
        row1.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row1, text="アーカイブ:", **lbl_s).pack(side=tk.LEFT)
        self.extract_file_var = tk.StringVar()
        ttk.Entry(row1, textvariable=self.extract_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row1, text="📂 参照",
                   command=self._browse_archive).pack(side=tk.LEFT)

        # 出力先
        row2 = tk.Frame(parent, bg="#f8f9fc")
        row2.pack(fill=tk.X, padx=8, pady=4)
        tk.Label(row2, text="解凍先:", **lbl_s).pack(side=tk.LEFT)
        self.extract_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.extract_out_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂 参照",
                   command=lambda: self.extract_out_var.set(
                       filedialog.askdirectory() or self.extract_out_var.get())
                   ).pack(side=tk.LEFT)

        # オプション
        opt_f = ttk.LabelFrame(parent, text="オプション", padding=6)
        opt_f.pack(fill=tk.X, padx=8, pady=4)
        self.extract_subdir_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opt_f, text="アーカイブ名のサブフォルダに解凍",
                         variable=self.extract_subdir_var).pack(anchor="w")
        self.extract_overwrite_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt_f, text="既存ファイルを上書き",
                         variable=self.extract_overwrite_var).pack(anchor="w")

        ttk.Button(parent, text="▶ 解凍実行",
                   command=self._extract).pack(padx=8, pady=6)

        self.extract_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.extract_progress.pack(fill=tk.X, padx=8, pady=2)

        # ログ
        log_f = ttk.LabelFrame(parent, text="解凍ログ", padding=4)
        log_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.extract_log = tk.Text(log_f, height=8, font=("Courier New", 10),
                                    state=tk.DISABLED, bg="#fafafa")
        self.extract_log.pack(fill=tk.BOTH, expand=True)

    def _browse_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.extract_file_var.set(path)
            if not self.extract_out_var.get():
                self.extract_out_var.set(os.path.dirname(path))

    def _extract(self):
        archive = self.extract_file_var.get().strip()
        if not archive or not os.path.exists(archive):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        out = self.extract_out_var.get().strip() or os.path.dirname(archive)
        if self.extract_subdir_var.get():
            base = os.path.splitext(os.path.basename(archive))[0]
            if base.endswith(".tar"):
                base = base[:-4]
            out = os.path.join(out, base)
        os.makedirs(out, exist_ok=True)
        self.extract_progress.start(10)
        threading.Thread(target=self._do_extract,
                         args=(archive, out), daemon=True).start()

    def _do_extract(self, archive, out):
        try:
            extracted = 0
            if zipfile.is_zipfile(archive):
                with zipfile.ZipFile(archive, "r") as zf:
                    members = zf.namelist()
                    for m in members:
                        zf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m}")
            elif tarfile.is_tarfile(archive):
                with tarfile.open(archive) as tf:
                    members = tf.getmembers()
                    for m in members:
                        tf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m.name}")
            else:
                raise ValueError("サポートされていない形式です")

            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, self._log_extract,
                            f"\n✓ 解凍完了: {extracted} ファイル → {out}")
            self.root.after(0, self.status_var.set,
                            f"解凍完了: {extracted} ファイル")
        except Exception as e:
            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    def _log_extract(self, text):
        self.extract_log.config(state=tk.NORMAL)
        self.extract_log.insert(tk.END, text + "\n")
        self.extract_log.see(tk.END)
        self.extract_log.config(state=tk.DISABLED)

    # ── 内容確認タブ ──────────────────────────────────────────────

    def _build_view_tab(self, parent):
        row = tk.Frame(parent, bg="#f8f9fc")
        row.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row, text="アーカイブ:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.view_file_var = tk.StringVar()
        ttk.Entry(row, textvariable=self.view_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row, text="📂 参照",
                   command=self._browse_view_archive).pack(side=tk.LEFT)
        ttk.Button(row, text="🔍 内容確認",
                   command=self._view_archive).pack(side=tk.LEFT, padx=4)

        cols = ("name", "size", "compressed", "type", "date")
        self.view_tree = ttk.Treeview(parent, columns=cols, show="headings")
        for c, h, w in [("name", "ファイル名", 280), ("size", "元サイズ", 80),
                         ("compressed", "圧縮後", 80), ("type", "種別", 60),
                         ("date", "更新日", 130)]:
            self.view_tree.heading(c, text=h)
            self.view_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(parent, command=self.view_tree.yview)
        self.view_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.view_tree.pack(fill=tk.BOTH, expand=True, padx=8)

        self.view_info_var = tk.StringVar()
        tk.Label(parent, textvariable=self.view_info_var, bg="#f8f9fc",
                 font=("Arial", 9), fg="#555").pack(anchor="w", padx=8, pady=2)

    def _browse_view_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.view_file_var.set(path)
            self._view_archive()

    def _view_archive(self):
        path = self.view_file_var.get().strip()
        if not path or not os.path.exists(path):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        self.view_tree.delete(*self.view_tree.get_children())
        total_orig = 0
        total_comp = 0
        count = 0
        try:
            if zipfile.is_zipfile(path):
                with zipfile.ZipFile(path) as zf:
                    for info in zf.infolist():
                        kind = "📁" if info.is_dir() else "📄"
                        date_str = f"{info.date_time[0]}/{info.date_time[1]:02d}/{info.date_time[2]:02d}"
                        self.view_tree.insert(
                            "", "end",
                            values=(info.filename,
                                    self._fmt_size(info.file_size),
                                    self._fmt_size(info.compress_size),
                                    kind, date_str))
                        total_orig += info.file_size
                        total_comp += info.compress_size
                        count += 1
            elif tarfile.is_tarfile(path):
                with tarfile.open(path) as tf:
                    for m in tf.getmembers():
                        kind = "📁" if m.isdir() else "📄"
                        dt = datetime.fromtimestamp(m.mtime).strftime(
                            "%Y/%m/%d") if m.mtime else ""
                        self.view_tree.insert(
                            "", "end",
                            values=(m.name, self._fmt_size(m.size),
                                    "—", kind, dt))
                        total_orig += m.size
                        count += 1
            else:
                messagebox.showerror("エラー", "サポートされていない形式です")
                return

            ratio = (1 - total_comp / total_orig) * 100 if total_orig else 0
            self.view_info_var.set(
                f"合計: {count} アイテム  |  "
                f"元サイズ: {self._fmt_size(total_orig)}  |  "
                f"圧縮後: {self._fmt_size(total_comp)}  |  "
                f"圧縮率: {ratio:.1f}%")
            self.status_var.set(f"確認完了: {count} アイテム")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _fmt_size(self, size):
        for unit in ["B", "KB", "MB", "GB"]:
            if size < 1024:
                return f"{size:.0f}{unit}"
            size /= 1024
        return f"{size:.1f}TB"


if __name__ == "__main__":
    root = tk.Tk()
    app = App35(root)
    root.mainloop()

Entryウィジェットとイベントバインド

ttk.Entryで入力フィールドを作成します。bind('', ...)でEnterキー押下時に処理を実行できます。これにより、マウスを使わずキーボードだけで操作できるUXが実現できます。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import zipfile
import tarfile
import os
import threading
from datetime import datetime


class App35:
    """ファイル圧縮・解凍ツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("ファイル圧縮・解凍ツール")
        self.root.geometry("860x600")
        self.root.configure(bg="#f8f9fc")
        self._files_to_compress = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#01579b", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🗜️ ファイル圧縮・解凍ツール",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#01579b", fg="white").pack(side=tk.LEFT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 圧縮タブ
        compress_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(compress_tab, text="📦 圧縮")
        self._build_compress_tab(compress_tab)

        # 解凍タブ
        extract_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(extract_tab, text="📂 解凍")
        self._build_extract_tab(extract_tab)

        # アーカイブ閲覧タブ
        view_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(view_tab, text="🔍 内容確認")
        self._build_view_tab(view_tab)

        self.status_var = tk.StringVar(value="ファイルを選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── 圧縮タブ ──────────────────────────────────────────────────

    def _build_compress_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル/フォルダ追加
        top_f = tk.Frame(parent, bg="#f8f9fc")
        top_f.pack(fill=tk.X, padx=8, pady=6)
        ttk.Button(top_f, text="➕ ファイルを追加",
                   command=self._add_files_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="📂 フォルダを追加",
                   command=self._add_folder_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="🗑 リストをクリア",
                   command=self._clear_compress_list).pack(side=tk.LEFT, padx=4)

        # ファイルリスト
        list_f = ttk.LabelFrame(parent, text="圧縮するファイル/フォルダ", padding=4)
        list_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.compress_listbox = tk.Listbox(list_f, font=("Arial", 10), height=10)
        sb = ttk.Scrollbar(list_f, command=self.compress_listbox.yview)
        self.compress_listbox.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.compress_listbox.pack(fill=tk.BOTH, expand=True)

        # 設定
        setting_f = ttk.LabelFrame(parent, text="圧縮設定", padding=8)
        setting_f.pack(fill=tk.X, padx=8, pady=4)
        row1 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row1.pack(fill=tk.X, pady=2)

        tk.Label(row1, text="形式:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT)
        self.compress_fmt_var = tk.StringVar(value="ZIP")
        ttk.Combobox(row1, textvariable=self.compress_fmt_var,
                     values=["ZIP", "TAR", "TAR.GZ", "TAR.BZ2"],
                     state="readonly", width=10).pack(side=tk.LEFT, padx=6)

        tk.Label(row1, text="圧縮レベル:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.compress_level_var = tk.IntVar(value=6)
        ttk.Spinbox(row1, from_=1, to=9,
                    textvariable=self.compress_level_var, width=4).pack(side=tk.LEFT, padx=4)

        row2 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row2.pack(fill=tk.X, pady=4)
        tk.Label(row2, text="出力先:", **lbl_s,
                 bg=row2.cget("bg")).pack(side=tk.LEFT)
        self.compress_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.compress_out_var,
                  width=40).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂",
                   command=self._browse_compress_out).pack(side=tk.LEFT)

        ttk.Button(parent, text="▶ 圧縮実行",
                   command=self._compress).pack(padx=8, pady=6)

        self.compress_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.compress_progress.pack(fill=tk.X, padx=8, pady=2)

    def _add_files_compress(self):
        paths = filedialog.askopenfilenames()
        for p in paths:
            if p not in self._files_to_compress:
                self._files_to_compress.append(p)
                self.compress_listbox.insert(tk.END, p)
        self._suggest_output()

    def _add_folder_compress(self):
        path = filedialog.askdirectory()
        if path and path not in self._files_to_compress:
            self._files_to_compress.append(path)
            self.compress_listbox.insert(tk.END, path)
        self._suggest_output()

    def _clear_compress_list(self):
        self._files_to_compress.clear()
        self.compress_listbox.delete(0, tk.END)

    def _suggest_output(self):
        if self._files_to_compress and not self.compress_out_var.get():
            first = self._files_to_compress[0]
            base = os.path.splitext(os.path.basename(first))[0]
            folder = os.path.dirname(first)
            fmt = self.compress_fmt_var.get().lower().replace(".", "")
            ext = ".zip" if fmt == "zip" else ".tar" if fmt == "tar" else \
                  ".tar.gz" if fmt == "targz" else ".tar.bz2"
            self.compress_out_var.set(os.path.join(folder, base + ext))

    def _browse_compress_out(self):
        fmt = self.compress_fmt_var.get()
        ft = [("ZIP", "*.zip")] if fmt == "ZIP" else \
             [("TAR", "*.tar"), ("TAR.GZ", "*.tar.gz"), ("TAR.BZ2", "*.tar.bz2")]
        path = filedialog.asksaveasfilename(filetypes=ft + [("すべて", "*.*")])
        if path:
            self.compress_out_var.set(path)

    def _compress(self):
        if not self._files_to_compress:
            messagebox.showwarning("警告", "ファイルを追加してください")
            return
        out = self.compress_out_var.get().strip()
        if not out:
            messagebox.showwarning("警告", "出力先を指定してください")
            return
        self.compress_progress.start(10)
        threading.Thread(target=self._do_compress,
                         args=(list(self._files_to_compress), out),
                         daemon=True).start()

    def _do_compress(self, files, out):
        fmt = self.compress_fmt_var.get()
        level = self.compress_level_var.get()
        try:
            if fmt == "ZIP":
                with zipfile.ZipFile(out, "w",
                                     compression=zipfile.ZIP_DEFLATED,
                                     compresslevel=level) as zf:
                    for path in files:
                        if os.path.isdir(path):
                            for dirpath, _, filenames in os.walk(path):
                                for fn in filenames:
                                    fp = os.path.join(dirpath, fn)
                                    arcname = os.path.relpath(fp, os.path.dirname(path))
                                    zf.write(fp, arcname)
                        else:
                            zf.write(path, os.path.basename(path))
            else:
                mode_map = {"TAR": "w", "TAR.GZ": "w:gz", "TAR.BZ2": "w:bz2"}
                mode = mode_map[fmt]
                with tarfile.open(out, mode) as tf:
                    for path in files:
                        tf.add(path, arcname=os.path.basename(path))

            size_kb = os.path.getsize(out) // 1024
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, self.status_var.set,
                            f"圧縮完了: {out} ({size_kb}KB)")
            self.root.after(0, messagebox.showinfo, "完了",
                            f"圧縮完了!\n{out}\n({size_kb}KB)")
        except Exception as e:
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    # ── 解凍タブ ──────────────────────────────────────────────────

    def _build_extract_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル選択
        row1 = tk.Frame(parent, bg="#f8f9fc")
        row1.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row1, text="アーカイブ:", **lbl_s).pack(side=tk.LEFT)
        self.extract_file_var = tk.StringVar()
        ttk.Entry(row1, textvariable=self.extract_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row1, text="📂 参照",
                   command=self._browse_archive).pack(side=tk.LEFT)

        # 出力先
        row2 = tk.Frame(parent, bg="#f8f9fc")
        row2.pack(fill=tk.X, padx=8, pady=4)
        tk.Label(row2, text="解凍先:", **lbl_s).pack(side=tk.LEFT)
        self.extract_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.extract_out_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂 参照",
                   command=lambda: self.extract_out_var.set(
                       filedialog.askdirectory() or self.extract_out_var.get())
                   ).pack(side=tk.LEFT)

        # オプション
        opt_f = ttk.LabelFrame(parent, text="オプション", padding=6)
        opt_f.pack(fill=tk.X, padx=8, pady=4)
        self.extract_subdir_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opt_f, text="アーカイブ名のサブフォルダに解凍",
                         variable=self.extract_subdir_var).pack(anchor="w")
        self.extract_overwrite_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt_f, text="既存ファイルを上書き",
                         variable=self.extract_overwrite_var).pack(anchor="w")

        ttk.Button(parent, text="▶ 解凍実行",
                   command=self._extract).pack(padx=8, pady=6)

        self.extract_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.extract_progress.pack(fill=tk.X, padx=8, pady=2)

        # ログ
        log_f = ttk.LabelFrame(parent, text="解凍ログ", padding=4)
        log_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.extract_log = tk.Text(log_f, height=8, font=("Courier New", 10),
                                    state=tk.DISABLED, bg="#fafafa")
        self.extract_log.pack(fill=tk.BOTH, expand=True)

    def _browse_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.extract_file_var.set(path)
            if not self.extract_out_var.get():
                self.extract_out_var.set(os.path.dirname(path))

    def _extract(self):
        archive = self.extract_file_var.get().strip()
        if not archive or not os.path.exists(archive):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        out = self.extract_out_var.get().strip() or os.path.dirname(archive)
        if self.extract_subdir_var.get():
            base = os.path.splitext(os.path.basename(archive))[0]
            if base.endswith(".tar"):
                base = base[:-4]
            out = os.path.join(out, base)
        os.makedirs(out, exist_ok=True)
        self.extract_progress.start(10)
        threading.Thread(target=self._do_extract,
                         args=(archive, out), daemon=True).start()

    def _do_extract(self, archive, out):
        try:
            extracted = 0
            if zipfile.is_zipfile(archive):
                with zipfile.ZipFile(archive, "r") as zf:
                    members = zf.namelist()
                    for m in members:
                        zf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m}")
            elif tarfile.is_tarfile(archive):
                with tarfile.open(archive) as tf:
                    members = tf.getmembers()
                    for m in members:
                        tf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m.name}")
            else:
                raise ValueError("サポートされていない形式です")

            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, self._log_extract,
                            f"\n✓ 解凍完了: {extracted} ファイル → {out}")
            self.root.after(0, self.status_var.set,
                            f"解凍完了: {extracted} ファイル")
        except Exception as e:
            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    def _log_extract(self, text):
        self.extract_log.config(state=tk.NORMAL)
        self.extract_log.insert(tk.END, text + "\n")
        self.extract_log.see(tk.END)
        self.extract_log.config(state=tk.DISABLED)

    # ── 内容確認タブ ──────────────────────────────────────────────

    def _build_view_tab(self, parent):
        row = tk.Frame(parent, bg="#f8f9fc")
        row.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row, text="アーカイブ:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.view_file_var = tk.StringVar()
        ttk.Entry(row, textvariable=self.view_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row, text="📂 参照",
                   command=self._browse_view_archive).pack(side=tk.LEFT)
        ttk.Button(row, text="🔍 内容確認",
                   command=self._view_archive).pack(side=tk.LEFT, padx=4)

        cols = ("name", "size", "compressed", "type", "date")
        self.view_tree = ttk.Treeview(parent, columns=cols, show="headings")
        for c, h, w in [("name", "ファイル名", 280), ("size", "元サイズ", 80),
                         ("compressed", "圧縮後", 80), ("type", "種別", 60),
                         ("date", "更新日", 130)]:
            self.view_tree.heading(c, text=h)
            self.view_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(parent, command=self.view_tree.yview)
        self.view_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.view_tree.pack(fill=tk.BOTH, expand=True, padx=8)

        self.view_info_var = tk.StringVar()
        tk.Label(parent, textvariable=self.view_info_var, bg="#f8f9fc",
                 font=("Arial", 9), fg="#555").pack(anchor="w", padx=8, pady=2)

    def _browse_view_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.view_file_var.set(path)
            self._view_archive()

    def _view_archive(self):
        path = self.view_file_var.get().strip()
        if not path or not os.path.exists(path):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        self.view_tree.delete(*self.view_tree.get_children())
        total_orig = 0
        total_comp = 0
        count = 0
        try:
            if zipfile.is_zipfile(path):
                with zipfile.ZipFile(path) as zf:
                    for info in zf.infolist():
                        kind = "📁" if info.is_dir() else "📄"
                        date_str = f"{info.date_time[0]}/{info.date_time[1]:02d}/{info.date_time[2]:02d}"
                        self.view_tree.insert(
                            "", "end",
                            values=(info.filename,
                                    self._fmt_size(info.file_size),
                                    self._fmt_size(info.compress_size),
                                    kind, date_str))
                        total_orig += info.file_size
                        total_comp += info.compress_size
                        count += 1
            elif tarfile.is_tarfile(path):
                with tarfile.open(path) as tf:
                    for m in tf.getmembers():
                        kind = "📁" if m.isdir() else "📄"
                        dt = datetime.fromtimestamp(m.mtime).strftime(
                            "%Y/%m/%d") if m.mtime else ""
                        self.view_tree.insert(
                            "", "end",
                            values=(m.name, self._fmt_size(m.size),
                                    "—", kind, dt))
                        total_orig += m.size
                        count += 1
            else:
                messagebox.showerror("エラー", "サポートされていない形式です")
                return

            ratio = (1 - total_comp / total_orig) * 100 if total_orig else 0
            self.view_info_var.set(
                f"合計: {count} アイテム  |  "
                f"元サイズ: {self._fmt_size(total_orig)}  |  "
                f"圧縮後: {self._fmt_size(total_comp)}  |  "
                f"圧縮率: {ratio:.1f}%")
            self.status_var.set(f"確認完了: {count} アイテム")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _fmt_size(self, size):
        for unit in ["B", "KB", "MB", "GB"]:
            if size < 1024:
                return f"{size:.0f}{unit}"
            size /= 1024
        return f"{size:.1f}TB"


if __name__ == "__main__":
    root = tk.Tk()
    app = App35(root)
    root.mainloop()

Textウィジェットでの結果表示

結果表示にはtk.Textウィジェットを使います。state=tk.DISABLEDでユーザーが直接編集できないようにし、表示前にNORMALに切り替えてからinsert()で内容を更新します。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import zipfile
import tarfile
import os
import threading
from datetime import datetime


class App35:
    """ファイル圧縮・解凍ツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("ファイル圧縮・解凍ツール")
        self.root.geometry("860x600")
        self.root.configure(bg="#f8f9fc")
        self._files_to_compress = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#01579b", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🗜️ ファイル圧縮・解凍ツール",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#01579b", fg="white").pack(side=tk.LEFT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 圧縮タブ
        compress_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(compress_tab, text="📦 圧縮")
        self._build_compress_tab(compress_tab)

        # 解凍タブ
        extract_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(extract_tab, text="📂 解凍")
        self._build_extract_tab(extract_tab)

        # アーカイブ閲覧タブ
        view_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(view_tab, text="🔍 内容確認")
        self._build_view_tab(view_tab)

        self.status_var = tk.StringVar(value="ファイルを選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── 圧縮タブ ──────────────────────────────────────────────────

    def _build_compress_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル/フォルダ追加
        top_f = tk.Frame(parent, bg="#f8f9fc")
        top_f.pack(fill=tk.X, padx=8, pady=6)
        ttk.Button(top_f, text="➕ ファイルを追加",
                   command=self._add_files_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="📂 フォルダを追加",
                   command=self._add_folder_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="🗑 リストをクリア",
                   command=self._clear_compress_list).pack(side=tk.LEFT, padx=4)

        # ファイルリスト
        list_f = ttk.LabelFrame(parent, text="圧縮するファイル/フォルダ", padding=4)
        list_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.compress_listbox = tk.Listbox(list_f, font=("Arial", 10), height=10)
        sb = ttk.Scrollbar(list_f, command=self.compress_listbox.yview)
        self.compress_listbox.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.compress_listbox.pack(fill=tk.BOTH, expand=True)

        # 設定
        setting_f = ttk.LabelFrame(parent, text="圧縮設定", padding=8)
        setting_f.pack(fill=tk.X, padx=8, pady=4)
        row1 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row1.pack(fill=tk.X, pady=2)

        tk.Label(row1, text="形式:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT)
        self.compress_fmt_var = tk.StringVar(value="ZIP")
        ttk.Combobox(row1, textvariable=self.compress_fmt_var,
                     values=["ZIP", "TAR", "TAR.GZ", "TAR.BZ2"],
                     state="readonly", width=10).pack(side=tk.LEFT, padx=6)

        tk.Label(row1, text="圧縮レベル:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.compress_level_var = tk.IntVar(value=6)
        ttk.Spinbox(row1, from_=1, to=9,
                    textvariable=self.compress_level_var, width=4).pack(side=tk.LEFT, padx=4)

        row2 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row2.pack(fill=tk.X, pady=4)
        tk.Label(row2, text="出力先:", **lbl_s,
                 bg=row2.cget("bg")).pack(side=tk.LEFT)
        self.compress_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.compress_out_var,
                  width=40).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂",
                   command=self._browse_compress_out).pack(side=tk.LEFT)

        ttk.Button(parent, text="▶ 圧縮実行",
                   command=self._compress).pack(padx=8, pady=6)

        self.compress_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.compress_progress.pack(fill=tk.X, padx=8, pady=2)

    def _add_files_compress(self):
        paths = filedialog.askopenfilenames()
        for p in paths:
            if p not in self._files_to_compress:
                self._files_to_compress.append(p)
                self.compress_listbox.insert(tk.END, p)
        self._suggest_output()

    def _add_folder_compress(self):
        path = filedialog.askdirectory()
        if path and path not in self._files_to_compress:
            self._files_to_compress.append(path)
            self.compress_listbox.insert(tk.END, path)
        self._suggest_output()

    def _clear_compress_list(self):
        self._files_to_compress.clear()
        self.compress_listbox.delete(0, tk.END)

    def _suggest_output(self):
        if self._files_to_compress and not self.compress_out_var.get():
            first = self._files_to_compress[0]
            base = os.path.splitext(os.path.basename(first))[0]
            folder = os.path.dirname(first)
            fmt = self.compress_fmt_var.get().lower().replace(".", "")
            ext = ".zip" if fmt == "zip" else ".tar" if fmt == "tar" else \
                  ".tar.gz" if fmt == "targz" else ".tar.bz2"
            self.compress_out_var.set(os.path.join(folder, base + ext))

    def _browse_compress_out(self):
        fmt = self.compress_fmt_var.get()
        ft = [("ZIP", "*.zip")] if fmt == "ZIP" else \
             [("TAR", "*.tar"), ("TAR.GZ", "*.tar.gz"), ("TAR.BZ2", "*.tar.bz2")]
        path = filedialog.asksaveasfilename(filetypes=ft + [("すべて", "*.*")])
        if path:
            self.compress_out_var.set(path)

    def _compress(self):
        if not self._files_to_compress:
            messagebox.showwarning("警告", "ファイルを追加してください")
            return
        out = self.compress_out_var.get().strip()
        if not out:
            messagebox.showwarning("警告", "出力先を指定してください")
            return
        self.compress_progress.start(10)
        threading.Thread(target=self._do_compress,
                         args=(list(self._files_to_compress), out),
                         daemon=True).start()

    def _do_compress(self, files, out):
        fmt = self.compress_fmt_var.get()
        level = self.compress_level_var.get()
        try:
            if fmt == "ZIP":
                with zipfile.ZipFile(out, "w",
                                     compression=zipfile.ZIP_DEFLATED,
                                     compresslevel=level) as zf:
                    for path in files:
                        if os.path.isdir(path):
                            for dirpath, _, filenames in os.walk(path):
                                for fn in filenames:
                                    fp = os.path.join(dirpath, fn)
                                    arcname = os.path.relpath(fp, os.path.dirname(path))
                                    zf.write(fp, arcname)
                        else:
                            zf.write(path, os.path.basename(path))
            else:
                mode_map = {"TAR": "w", "TAR.GZ": "w:gz", "TAR.BZ2": "w:bz2"}
                mode = mode_map[fmt]
                with tarfile.open(out, mode) as tf:
                    for path in files:
                        tf.add(path, arcname=os.path.basename(path))

            size_kb = os.path.getsize(out) // 1024
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, self.status_var.set,
                            f"圧縮完了: {out} ({size_kb}KB)")
            self.root.after(0, messagebox.showinfo, "完了",
                            f"圧縮完了!\n{out}\n({size_kb}KB)")
        except Exception as e:
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    # ── 解凍タブ ──────────────────────────────────────────────────

    def _build_extract_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル選択
        row1 = tk.Frame(parent, bg="#f8f9fc")
        row1.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row1, text="アーカイブ:", **lbl_s).pack(side=tk.LEFT)
        self.extract_file_var = tk.StringVar()
        ttk.Entry(row1, textvariable=self.extract_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row1, text="📂 参照",
                   command=self._browse_archive).pack(side=tk.LEFT)

        # 出力先
        row2 = tk.Frame(parent, bg="#f8f9fc")
        row2.pack(fill=tk.X, padx=8, pady=4)
        tk.Label(row2, text="解凍先:", **lbl_s).pack(side=tk.LEFT)
        self.extract_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.extract_out_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂 参照",
                   command=lambda: self.extract_out_var.set(
                       filedialog.askdirectory() or self.extract_out_var.get())
                   ).pack(side=tk.LEFT)

        # オプション
        opt_f = ttk.LabelFrame(parent, text="オプション", padding=6)
        opt_f.pack(fill=tk.X, padx=8, pady=4)
        self.extract_subdir_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opt_f, text="アーカイブ名のサブフォルダに解凍",
                         variable=self.extract_subdir_var).pack(anchor="w")
        self.extract_overwrite_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt_f, text="既存ファイルを上書き",
                         variable=self.extract_overwrite_var).pack(anchor="w")

        ttk.Button(parent, text="▶ 解凍実行",
                   command=self._extract).pack(padx=8, pady=6)

        self.extract_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.extract_progress.pack(fill=tk.X, padx=8, pady=2)

        # ログ
        log_f = ttk.LabelFrame(parent, text="解凍ログ", padding=4)
        log_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.extract_log = tk.Text(log_f, height=8, font=("Courier New", 10),
                                    state=tk.DISABLED, bg="#fafafa")
        self.extract_log.pack(fill=tk.BOTH, expand=True)

    def _browse_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.extract_file_var.set(path)
            if not self.extract_out_var.get():
                self.extract_out_var.set(os.path.dirname(path))

    def _extract(self):
        archive = self.extract_file_var.get().strip()
        if not archive or not os.path.exists(archive):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        out = self.extract_out_var.get().strip() or os.path.dirname(archive)
        if self.extract_subdir_var.get():
            base = os.path.splitext(os.path.basename(archive))[0]
            if base.endswith(".tar"):
                base = base[:-4]
            out = os.path.join(out, base)
        os.makedirs(out, exist_ok=True)
        self.extract_progress.start(10)
        threading.Thread(target=self._do_extract,
                         args=(archive, out), daemon=True).start()

    def _do_extract(self, archive, out):
        try:
            extracted = 0
            if zipfile.is_zipfile(archive):
                with zipfile.ZipFile(archive, "r") as zf:
                    members = zf.namelist()
                    for m in members:
                        zf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m}")
            elif tarfile.is_tarfile(archive):
                with tarfile.open(archive) as tf:
                    members = tf.getmembers()
                    for m in members:
                        tf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m.name}")
            else:
                raise ValueError("サポートされていない形式です")

            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, self._log_extract,
                            f"\n✓ 解凍完了: {extracted} ファイル → {out}")
            self.root.after(0, self.status_var.set,
                            f"解凍完了: {extracted} ファイル")
        except Exception as e:
            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    def _log_extract(self, text):
        self.extract_log.config(state=tk.NORMAL)
        self.extract_log.insert(tk.END, text + "\n")
        self.extract_log.see(tk.END)
        self.extract_log.config(state=tk.DISABLED)

    # ── 内容確認タブ ──────────────────────────────────────────────

    def _build_view_tab(self, parent):
        row = tk.Frame(parent, bg="#f8f9fc")
        row.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row, text="アーカイブ:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.view_file_var = tk.StringVar()
        ttk.Entry(row, textvariable=self.view_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row, text="📂 参照",
                   command=self._browse_view_archive).pack(side=tk.LEFT)
        ttk.Button(row, text="🔍 内容確認",
                   command=self._view_archive).pack(side=tk.LEFT, padx=4)

        cols = ("name", "size", "compressed", "type", "date")
        self.view_tree = ttk.Treeview(parent, columns=cols, show="headings")
        for c, h, w in [("name", "ファイル名", 280), ("size", "元サイズ", 80),
                         ("compressed", "圧縮後", 80), ("type", "種別", 60),
                         ("date", "更新日", 130)]:
            self.view_tree.heading(c, text=h)
            self.view_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(parent, command=self.view_tree.yview)
        self.view_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.view_tree.pack(fill=tk.BOTH, expand=True, padx=8)

        self.view_info_var = tk.StringVar()
        tk.Label(parent, textvariable=self.view_info_var, bg="#f8f9fc",
                 font=("Arial", 9), fg="#555").pack(anchor="w", padx=8, pady=2)

    def _browse_view_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.view_file_var.set(path)
            self._view_archive()

    def _view_archive(self):
        path = self.view_file_var.get().strip()
        if not path or not os.path.exists(path):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        self.view_tree.delete(*self.view_tree.get_children())
        total_orig = 0
        total_comp = 0
        count = 0
        try:
            if zipfile.is_zipfile(path):
                with zipfile.ZipFile(path) as zf:
                    for info in zf.infolist():
                        kind = "📁" if info.is_dir() else "📄"
                        date_str = f"{info.date_time[0]}/{info.date_time[1]:02d}/{info.date_time[2]:02d}"
                        self.view_tree.insert(
                            "", "end",
                            values=(info.filename,
                                    self._fmt_size(info.file_size),
                                    self._fmt_size(info.compress_size),
                                    kind, date_str))
                        total_orig += info.file_size
                        total_comp += info.compress_size
                        count += 1
            elif tarfile.is_tarfile(path):
                with tarfile.open(path) as tf:
                    for m in tf.getmembers():
                        kind = "📁" if m.isdir() else "📄"
                        dt = datetime.fromtimestamp(m.mtime).strftime(
                            "%Y/%m/%d") if m.mtime else ""
                        self.view_tree.insert(
                            "", "end",
                            values=(m.name, self._fmt_size(m.size),
                                    "—", kind, dt))
                        total_orig += m.size
                        count += 1
            else:
                messagebox.showerror("エラー", "サポートされていない形式です")
                return

            ratio = (1 - total_comp / total_orig) * 100 if total_orig else 0
            self.view_info_var.set(
                f"合計: {count} アイテム  |  "
                f"元サイズ: {self._fmt_size(total_orig)}  |  "
                f"圧縮後: {self._fmt_size(total_comp)}  |  "
                f"圧縮率: {ratio:.1f}%")
            self.status_var.set(f"確認完了: {count} アイテム")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _fmt_size(self, size):
        for unit in ["B", "KB", "MB", "GB"]:
            if size < 1024:
                return f"{size:.0f}{unit}"
            size /= 1024
        return f"{size:.1f}TB"


if __name__ == "__main__":
    root = tk.Tk()
    app = App35(root)
    root.mainloop()

例外処理とmessagebox

try-except で ValueError と Exception を捕捉し、messagebox.showerror() でユーザーにわかりやすいエラーメッセージを表示します。入力バリデーションは必ず実装しましょう。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import zipfile
import tarfile
import os
import threading
from datetime import datetime


class App35:
    """ファイル圧縮・解凍ツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("ファイル圧縮・解凍ツール")
        self.root.geometry("860x600")
        self.root.configure(bg="#f8f9fc")
        self._files_to_compress = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#01579b", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🗜️ ファイル圧縮・解凍ツール",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#01579b", fg="white").pack(side=tk.LEFT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 圧縮タブ
        compress_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(compress_tab, text="📦 圧縮")
        self._build_compress_tab(compress_tab)

        # 解凍タブ
        extract_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(extract_tab, text="📂 解凍")
        self._build_extract_tab(extract_tab)

        # アーカイブ閲覧タブ
        view_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(view_tab, text="🔍 内容確認")
        self._build_view_tab(view_tab)

        self.status_var = tk.StringVar(value="ファイルを選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── 圧縮タブ ──────────────────────────────────────────────────

    def _build_compress_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル/フォルダ追加
        top_f = tk.Frame(parent, bg="#f8f9fc")
        top_f.pack(fill=tk.X, padx=8, pady=6)
        ttk.Button(top_f, text="➕ ファイルを追加",
                   command=self._add_files_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="📂 フォルダを追加",
                   command=self._add_folder_compress).pack(side=tk.LEFT, padx=4)
        ttk.Button(top_f, text="🗑 リストをクリア",
                   command=self._clear_compress_list).pack(side=tk.LEFT, padx=4)

        # ファイルリスト
        list_f = ttk.LabelFrame(parent, text="圧縮するファイル/フォルダ", padding=4)
        list_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.compress_listbox = tk.Listbox(list_f, font=("Arial", 10), height=10)
        sb = ttk.Scrollbar(list_f, command=self.compress_listbox.yview)
        self.compress_listbox.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.compress_listbox.pack(fill=tk.BOTH, expand=True)

        # 設定
        setting_f = ttk.LabelFrame(parent, text="圧縮設定", padding=8)
        setting_f.pack(fill=tk.X, padx=8, pady=4)
        row1 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row1.pack(fill=tk.X, pady=2)

        tk.Label(row1, text="形式:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT)
        self.compress_fmt_var = tk.StringVar(value="ZIP")
        ttk.Combobox(row1, textvariable=self.compress_fmt_var,
                     values=["ZIP", "TAR", "TAR.GZ", "TAR.BZ2"],
                     state="readonly", width=10).pack(side=tk.LEFT, padx=6)

        tk.Label(row1, text="圧縮レベル:", **lbl_s,
                 bg=row1.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.compress_level_var = tk.IntVar(value=6)
        ttk.Spinbox(row1, from_=1, to=9,
                    textvariable=self.compress_level_var, width=4).pack(side=tk.LEFT, padx=4)

        row2 = tk.Frame(setting_f, bg=setting_f.cget("background"))
        row2.pack(fill=tk.X, pady=4)
        tk.Label(row2, text="出力先:", **lbl_s,
                 bg=row2.cget("bg")).pack(side=tk.LEFT)
        self.compress_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.compress_out_var,
                  width=40).pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂",
                   command=self._browse_compress_out).pack(side=tk.LEFT)

        ttk.Button(parent, text="▶ 圧縮実行",
                   command=self._compress).pack(padx=8, pady=6)

        self.compress_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.compress_progress.pack(fill=tk.X, padx=8, pady=2)

    def _add_files_compress(self):
        paths = filedialog.askopenfilenames()
        for p in paths:
            if p not in self._files_to_compress:
                self._files_to_compress.append(p)
                self.compress_listbox.insert(tk.END, p)
        self._suggest_output()

    def _add_folder_compress(self):
        path = filedialog.askdirectory()
        if path and path not in self._files_to_compress:
            self._files_to_compress.append(path)
            self.compress_listbox.insert(tk.END, path)
        self._suggest_output()

    def _clear_compress_list(self):
        self._files_to_compress.clear()
        self.compress_listbox.delete(0, tk.END)

    def _suggest_output(self):
        if self._files_to_compress and not self.compress_out_var.get():
            first = self._files_to_compress[0]
            base = os.path.splitext(os.path.basename(first))[0]
            folder = os.path.dirname(first)
            fmt = self.compress_fmt_var.get().lower().replace(".", "")
            ext = ".zip" if fmt == "zip" else ".tar" if fmt == "tar" else \
                  ".tar.gz" if fmt == "targz" else ".tar.bz2"
            self.compress_out_var.set(os.path.join(folder, base + ext))

    def _browse_compress_out(self):
        fmt = self.compress_fmt_var.get()
        ft = [("ZIP", "*.zip")] if fmt == "ZIP" else \
             [("TAR", "*.tar"), ("TAR.GZ", "*.tar.gz"), ("TAR.BZ2", "*.tar.bz2")]
        path = filedialog.asksaveasfilename(filetypes=ft + [("すべて", "*.*")])
        if path:
            self.compress_out_var.set(path)

    def _compress(self):
        if not self._files_to_compress:
            messagebox.showwarning("警告", "ファイルを追加してください")
            return
        out = self.compress_out_var.get().strip()
        if not out:
            messagebox.showwarning("警告", "出力先を指定してください")
            return
        self.compress_progress.start(10)
        threading.Thread(target=self._do_compress,
                         args=(list(self._files_to_compress), out),
                         daemon=True).start()

    def _do_compress(self, files, out):
        fmt = self.compress_fmt_var.get()
        level = self.compress_level_var.get()
        try:
            if fmt == "ZIP":
                with zipfile.ZipFile(out, "w",
                                     compression=zipfile.ZIP_DEFLATED,
                                     compresslevel=level) as zf:
                    for path in files:
                        if os.path.isdir(path):
                            for dirpath, _, filenames in os.walk(path):
                                for fn in filenames:
                                    fp = os.path.join(dirpath, fn)
                                    arcname = os.path.relpath(fp, os.path.dirname(path))
                                    zf.write(fp, arcname)
                        else:
                            zf.write(path, os.path.basename(path))
            else:
                mode_map = {"TAR": "w", "TAR.GZ": "w:gz", "TAR.BZ2": "w:bz2"}
                mode = mode_map[fmt]
                with tarfile.open(out, mode) as tf:
                    for path in files:
                        tf.add(path, arcname=os.path.basename(path))

            size_kb = os.path.getsize(out) // 1024
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, self.status_var.set,
                            f"圧縮完了: {out} ({size_kb}KB)")
            self.root.after(0, messagebox.showinfo, "完了",
                            f"圧縮完了!\n{out}\n({size_kb}KB)")
        except Exception as e:
            self.root.after(0, self.compress_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    # ── 解凍タブ ──────────────────────────────────────────────────

    def _build_extract_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # ファイル選択
        row1 = tk.Frame(parent, bg="#f8f9fc")
        row1.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row1, text="アーカイブ:", **lbl_s).pack(side=tk.LEFT)
        self.extract_file_var = tk.StringVar()
        ttk.Entry(row1, textvariable=self.extract_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row1, text="📂 参照",
                   command=self._browse_archive).pack(side=tk.LEFT)

        # 出力先
        row2 = tk.Frame(parent, bg="#f8f9fc")
        row2.pack(fill=tk.X, padx=8, pady=4)
        tk.Label(row2, text="解凍先:", **lbl_s).pack(side=tk.LEFT)
        self.extract_out_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.extract_out_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row2, text="📂 参照",
                   command=lambda: self.extract_out_var.set(
                       filedialog.askdirectory() or self.extract_out_var.get())
                   ).pack(side=tk.LEFT)

        # オプション
        opt_f = ttk.LabelFrame(parent, text="オプション", padding=6)
        opt_f.pack(fill=tk.X, padx=8, pady=4)
        self.extract_subdir_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opt_f, text="アーカイブ名のサブフォルダに解凍",
                         variable=self.extract_subdir_var).pack(anchor="w")
        self.extract_overwrite_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt_f, text="既存ファイルを上書き",
                         variable=self.extract_overwrite_var).pack(anchor="w")

        ttk.Button(parent, text="▶ 解凍実行",
                   command=self._extract).pack(padx=8, pady=6)

        self.extract_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.extract_progress.pack(fill=tk.X, padx=8, pady=2)

        # ログ
        log_f = ttk.LabelFrame(parent, text="解凍ログ", padding=4)
        log_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.extract_log = tk.Text(log_f, height=8, font=("Courier New", 10),
                                    state=tk.DISABLED, bg="#fafafa")
        self.extract_log.pack(fill=tk.BOTH, expand=True)

    def _browse_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.extract_file_var.set(path)
            if not self.extract_out_var.get():
                self.extract_out_var.set(os.path.dirname(path))

    def _extract(self):
        archive = self.extract_file_var.get().strip()
        if not archive or not os.path.exists(archive):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        out = self.extract_out_var.get().strip() or os.path.dirname(archive)
        if self.extract_subdir_var.get():
            base = os.path.splitext(os.path.basename(archive))[0]
            if base.endswith(".tar"):
                base = base[:-4]
            out = os.path.join(out, base)
        os.makedirs(out, exist_ok=True)
        self.extract_progress.start(10)
        threading.Thread(target=self._do_extract,
                         args=(archive, out), daemon=True).start()

    def _do_extract(self, archive, out):
        try:
            extracted = 0
            if zipfile.is_zipfile(archive):
                with zipfile.ZipFile(archive, "r") as zf:
                    members = zf.namelist()
                    for m in members:
                        zf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m}")
            elif tarfile.is_tarfile(archive):
                with tarfile.open(archive) as tf:
                    members = tf.getmembers()
                    for m in members:
                        tf.extract(m, out)
                        extracted += 1
                        self.root.after(0, self._log_extract, f"  {m.name}")
            else:
                raise ValueError("サポートされていない形式です")

            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, self._log_extract,
                            f"\n✓ 解凍完了: {extracted} ファイル → {out}")
            self.root.after(0, self.status_var.set,
                            f"解凍完了: {extracted} ファイル")
        except Exception as e:
            self.root.after(0, self.extract_progress.stop)
            self.root.after(0, messagebox.showerror, "エラー", str(e))

    def _log_extract(self, text):
        self.extract_log.config(state=tk.NORMAL)
        self.extract_log.insert(tk.END, text + "\n")
        self.extract_log.see(tk.END)
        self.extract_log.config(state=tk.DISABLED)

    # ── 内容確認タブ ──────────────────────────────────────────────

    def _build_view_tab(self, parent):
        row = tk.Frame(parent, bg="#f8f9fc")
        row.pack(fill=tk.X, padx=8, pady=8)
        tk.Label(row, text="アーカイブ:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.view_file_var = tk.StringVar()
        ttk.Entry(row, textvariable=self.view_file_var,
                  width=45).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)
        ttk.Button(row, text="📂 参照",
                   command=self._browse_view_archive).pack(side=tk.LEFT)
        ttk.Button(row, text="🔍 内容確認",
                   command=self._view_archive).pack(side=tk.LEFT, padx=4)

        cols = ("name", "size", "compressed", "type", "date")
        self.view_tree = ttk.Treeview(parent, columns=cols, show="headings")
        for c, h, w in [("name", "ファイル名", 280), ("size", "元サイズ", 80),
                         ("compressed", "圧縮後", 80), ("type", "種別", 60),
                         ("date", "更新日", 130)]:
            self.view_tree.heading(c, text=h)
            self.view_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(parent, command=self.view_tree.yview)
        self.view_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.view_tree.pack(fill=tk.BOTH, expand=True, padx=8)

        self.view_info_var = tk.StringVar()
        tk.Label(parent, textvariable=self.view_info_var, bg="#f8f9fc",
                 font=("Arial", 9), fg="#555").pack(anchor="w", padx=8, pady=2)

    def _browse_view_archive(self):
        path = filedialog.askopenfilename(
            filetypes=[("アーカイブ", "*.zip *.tar *.tar.gz *.tar.bz2 *.tgz"),
                       ("すべて", "*.*")])
        if path:
            self.view_file_var.set(path)
            self._view_archive()

    def _view_archive(self):
        path = self.view_file_var.get().strip()
        if not path or not os.path.exists(path):
            messagebox.showwarning("警告", "アーカイブファイルを選択してください")
            return
        self.view_tree.delete(*self.view_tree.get_children())
        total_orig = 0
        total_comp = 0
        count = 0
        try:
            if zipfile.is_zipfile(path):
                with zipfile.ZipFile(path) as zf:
                    for info in zf.infolist():
                        kind = "📁" if info.is_dir() else "📄"
                        date_str = f"{info.date_time[0]}/{info.date_time[1]:02d}/{info.date_time[2]:02d}"
                        self.view_tree.insert(
                            "", "end",
                            values=(info.filename,
                                    self._fmt_size(info.file_size),
                                    self._fmt_size(info.compress_size),
                                    kind, date_str))
                        total_orig += info.file_size
                        total_comp += info.compress_size
                        count += 1
            elif tarfile.is_tarfile(path):
                with tarfile.open(path) as tf:
                    for m in tf.getmembers():
                        kind = "📁" if m.isdir() else "📄"
                        dt = datetime.fromtimestamp(m.mtime).strftime(
                            "%Y/%m/%d") if m.mtime else ""
                        self.view_tree.insert(
                            "", "end",
                            values=(m.name, self._fmt_size(m.size),
                                    "—", kind, dt))
                        total_orig += m.size
                        count += 1
            else:
                messagebox.showerror("エラー", "サポートされていない形式です")
                return

            ratio = (1 - total_comp / total_orig) * 100 if total_orig else 0
            self.view_info_var.set(
                f"合計: {count} アイテム  |  "
                f"元サイズ: {self._fmt_size(total_orig)}  |  "
                f"圧縮後: {self._fmt_size(total_comp)}  |  "
                f"圧縮率: {ratio:.1f}%")
            self.status_var.set(f"確認完了: {count} アイテム")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _fmt_size(self, size):
        for unit in ["B", "KB", "MB", "GB"]:
            if size < 1024:
                return f"{size:.0f}{unit}"
            size /= 1024
        return f"{size:.1f}TB"


if __name__ == "__main__":
    root = tk.Tk()
    app = App35(root)
    root.mainloop()

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app35.py と保存します。

  2. 2
    クラスの骨格を作る

    App35クラスを定義し、__init__とmainloop()の最小構成を作ります。

  3. 3
    タイトルバーを作る

    Frameを使ってカラーバー付きのタイトルエリアを作ります。

  4. 4
    入力フォームを実装する

    LabelFrameとEntryウィジェットで入力エリアを作ります。

  5. 5
    処理ロジックを実装する

    _calculate()メソッドに計算・処理ロジックを実装します。

  6. 6
    結果表示を実装する

    TextウィジェットかLabelに結果を表示する_show_result()を実装します。

  7. 7
    エラー処理を追加する

    try-exceptとmessageboxでエラーハンドリングを追加します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 データのエクスポート機能

計算結果をCSV・TXTファイルに保存するエクスポート機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

8. よくある問題と解決法

❌ 日本語フォントが表示されない

原因:システムに日本語フォントが見つからない場合があります。

解決法:font引数を省略するかシステムに合ったフォントを指定してください。

❌ ウィンドウのサイズが変更できない

原因:resizable(False, False)が設定されています。

解決法:resizable(True, True)に変更してください。

9. 練習問題

アプリの理解を深めるための練習問題です。難易度順に挑戦してみてください。

  1. 課題1:機能拡張

    ファイル圧縮・解凍ツールに新しい機能を1つ追加してみましょう。どんな機能があると便利か考えてから実装してください。

  2. 課題2:UIの改善

    色・フォント・レイアウトを変更して、より使いやすいUIにカスタマイズしてみましょう。

  3. 課題3:保存機能の追加

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のNo.36に挑戦しましょう。