中級者向け No.50

マルチタブ簡易IDE

タブ付きエディタ・コード実行・出力パネル・ファイルツリーを統合した簡易IDE。集大成プロジェクトです。

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

1. アプリ概要

タブ付きエディタ・コード実行・出力パネル・ファイルツリーを統合した簡易IDE。集大成プロジェクトです。

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

このアプリは「ユーティリティ」カテゴリです。小回りの効くユーティリティは Python の真骨頂で、汎用的なユーザー操作を組み合わせる発想が他のアプリにもそのまま流用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

完成形を見てから細部の解説に進む流れが効果的です。実行→気になる箇所を解説で確認→自分の手で改造、というサイクルで定着が早まります。

カスタマイズ章では具体的な拡張アイデアを示しています。一つ実装するごとに動作確認することで、変更が予期せぬ副作用を起こさないかも体感できます。

マルチタブ簡易IDE 実行画面(Windows)
実行画面(Windows)
マルチタブ簡易IDE 実行画面(Linux Mint)。Python インタープリタのパス表示は環境情報のためマスク処理しています
実行画面(Linux Mint・一部をマスク処理)

2. 機能一覧

  • ループで生成するボタン群(それぞれ _new_tab()_open_file()_save_file()_run_code()_stop_code() を実行)
  • 「…」ボタン(_open_folder())・「次へ」ボタン(_find_next())・「置換」ボタン(_replace_one())・「全置換」ボタン(_replace_all())・「✕」ボタン(_hide_search())・「クリア」ボタン(_clear_terminal())で操作
  • キー <Control-t> から _new_tab() を実行
  • キー <Control-o> から _open_file() を実行
  • キー <Control-s> から _save_file() を実行
  • キー <Control-w> から _close_tab() を実行
  • キー <F5> から _run_code() を実行
  • マウス操作 <Double-1> から _on_tree_double_click() を実行
  • イベント <<NotebookTabChanged>> から _on_tab_changed() を実行
  • キー <KeyRelease> から 処理 を実行
  • マウス操作 <ButtonRelease-1> から _update_cursor_pos() を実行
  • ttk.Treeview による表形式の一覧表示
  • filedialog.askdirectory() によるファイル選択ダイアログ
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.askyesnocancel()messagebox.showerror() によるダイアログ通知
  • ウィンドウはタイトル「簡易IDE」・サイズ 1200x905 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

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

追加インストール不要(標準ライブラリのみ使用)
app50.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import os
import re
import sys
import subprocess
import threading
from datetime import datetime


class App50:
    """マルチタブ簡易IDE"""

    TAB_UNTITLED = "無題"

    PYTHON_KW = (
        r"\b(def|class|import|from|return|if|elif|else|for|while|"
        r"try|except|finally|with|as|pass|break|continue|in|not|and|or|is|"
        r"None|True|False|lambda|yield|async|await|raise|del|global|nonlocal)\b"
    )
    PYTHON_BUILTINS = (
        r"\b(print|len|range|type|str|int|float|list|dict|set|tuple|bool|"
        r"open|sum|max|min|sorted|enumerate|zip|map|filter|super|self|input|"
        r"abs|round|isinstance|hasattr|getattr|setattr|vars|dir|help)\b"
    )
    PYTHON_STR = (
        r'("""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'|f"[^"]*"|f\'[^\']*\'|'
        r'"[^"\n]*"|\'[^\'\n]*\')'
    )
    PYTHON_CMT = r"#[^\n]*"
    PYTHON_NUM = r"\b\d+(\.\d+)?\b"
    PYTHON_DECO = r"@\w+"

    SAMPLE_CODE = '''\
# 簡易IDE サンプル — Fibonacci
def fibonacci(n):
    """フィボナッチ数列を返す"""
    a, b = 0, 1
    result = []
    for _ in range(n):
        result.append(a)
        a, b = b, a + b
    return result

if __name__ == "__main__":
    nums = fibonacci(10)
    print("Fibonacci:", nums)
    print("Sum:", sum(nums))
'''

    def __init__(self, root):
        self.root = root
        self.root.title("簡易IDE")
        self.root.geometry("1200x905")
        self.root.minsize(1200, 905)
        self.root.configure(bg="#1e1e1e")

        self._tabs = {}      # tab_id -> {"path", "editor", "modified"}
        self._proc = None
        self._file_tree_path = None

        self._build_ui()
        self._new_tab()

    # ── UI構築 ────────────────────────────────────────────────────

    def _build_ui(self):
        # メニューバー
        menubar = tk.Menu(self.root, bg="#252526", fg="#ccc",
                          activebackground="#094771",
                          activeforeground="#fff", bd=0)
        self.root.configure(menu=menubar)

        file_menu = tk.Menu(menubar, tearoff=False, bg="#252526", fg="#ccc",
                            activebackground="#094771", activeforeground="#fff")
        menubar.add_cascade(label="ファイル", menu=file_menu)
        file_menu.add_command(label="新規タブ (Ctrl+T)",   command=self._new_tab)
        file_menu.add_command(label="開く (Ctrl+O)",       command=self._open_file)
        file_menu.add_command(label="保存 (Ctrl+S)",       command=self._save_file)
        file_menu.add_command(label="名前を付けて保存",     command=self._save_as)
        file_menu.add_separator()
        file_menu.add_command(label="タブを閉じる (Ctrl+W)", command=self._close_tab)
        file_menu.add_separator()
        file_menu.add_command(label="終了", command=self.root.quit)

        run_menu = tk.Menu(menubar, tearoff=False, bg="#252526", fg="#ccc",
                           activebackground="#094771", activeforeground="#fff")
        menubar.add_cascade(label="実行", menu=run_menu)
        run_menu.add_command(label="▶ 実行 (F5)",   command=self._run_code)
        run_menu.add_command(label="⏹ 停止",        command=self._stop_code)

        edit_menu = tk.Menu(menubar, tearoff=False, bg="#252526", fg="#ccc",
                            activebackground="#094771", activeforeground="#fff")
        menubar.add_cascade(label="編集", menu=edit_menu)
        edit_menu.add_command(label="検索/置換 (Ctrl+H)", command=self._show_search)

        # キーバインド
        self.root.bind("<Control-t>", lambda e: self._new_tab())
        self.root.bind("<Control-o>", lambda e: self._open_file())
        self.root.bind("<Control-s>", lambda e: self._save_file())
        self.root.bind("<Control-w>", lambda e: self._close_tab())
        self.root.bind("<F5>",        lambda e: self._run_code())

        # ツールバー
        toolbar = tk.Frame(self.root, bg="#2d2d2d", pady=4)
        toolbar.pack(fill=tk.X)
        for text, cmd in [("📄 新規", self._new_tab),
                           ("📂 開く", self._open_file),
                           ("💾 保存", self._save_file),
                           ("▶ 実行", self._run_code),
                           ("⏹ 停止", self._stop_code)]:
            tk.Button(toolbar, text=text, command=cmd,
                      bg="#3c3c3c", fg="#ccc", relief=tk.FLAT,
                      font=("Arial", 9), padx=8, pady=2,
                      activebackground="#505050", bd=0).pack(
                side=tk.LEFT, padx=2)

        tk.Label(toolbar, text="Python インタープリタ:", bg="#2d2d2d",
                 fg="#888", font=("Arial", 9)).pack(side=tk.LEFT, padx=(16, 2))
        self.python_path_var = tk.StringVar(value=sys.executable)
        ttk.Entry(toolbar, textvariable=self.python_path_var,
                  width=30).pack(side=tk.LEFT)

        self.run_status = tk.Label(toolbar, text="", bg="#2d2d2d",
                                    fg="#4fc3f7", font=("Arial", 9))
        self.run_status.pack(side=tk.RIGHT, padx=8)

        # メインエリア: ファイルツリー | タブエディタ
        main = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        main.pack(fill=tk.BOTH, expand=True)

        # 左: ファイルツリー
        tree_frame = tk.Frame(main, bg="#252526", width=180)
        main.add(tree_frame, weight=0)
        self._build_file_tree(tree_frame)

        # 右: エディタ + ターミナル
        right = tk.Frame(main, bg="#1e1e1e")
        main.add(right, weight=1)

        editor_paned = ttk.PanedWindow(right, orient=tk.VERTICAL)
        editor_paned.pack(fill=tk.BOTH, expand=True)

        # タブエディタ
        editor_area = tk.Frame(editor_paned, bg="#1e1e1e")
        editor_paned.add(editor_area, weight=3)
        self._build_editor_area(editor_area)

        # ターミナル出力
        term_area = tk.Frame(editor_paned, bg="#0d1117")
        editor_paned.add(term_area, weight=1)
        self._build_terminal(term_area)

        # ステータスバー
        self.status_var = tk.StringVar(value="準備完了")
        sb = tk.Frame(self.root, bg="#007acc", pady=2)
        sb.pack(fill=tk.X, side=tk.BOTTOM)
        self.status_lbl = tk.Label(sb, textvariable=self.status_var,
                                    bg="#007acc", fg="#fff",
                                    font=("Arial", 8), anchor="w", padx=8)
        self.status_lbl.pack(side=tk.LEFT)
        self.cursor_lbl = tk.Label(sb, text="行 1, 列 1",
                                    bg="#007acc", fg="#fff",
                                    font=("Arial", 8))
        self.cursor_lbl.pack(side=tk.RIGHT, padx=8)

    def _build_file_tree(self, parent):
        hdr = tk.Frame(parent, bg="#252526")
        hdr.pack(fill=tk.X)
        tk.Label(hdr, text="📁 エクスプローラー", bg="#252526",
                 fg="#ccc", font=("Arial", 9, "bold")).pack(
            side=tk.LEFT, padx=4, pady=4)
        ttk.Button(hdr, text="…",
                   command=self._open_folder).pack(side=tk.RIGHT, padx=2)

        self.file_tree = ttk.Treeview(parent, show="tree", selectmode="browse")
        fsb = ttk.Scrollbar(parent, command=self.file_tree.yview)
        self.file_tree.configure(yscrollcommand=fsb.set)
        fsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.file_tree.pack(fill=tk.BOTH, expand=True)
        self.file_tree.bind("<Double-1>", self._on_tree_double_click)

    def _build_editor_area(self, parent):
        # タブバー
        self.notebook = ttk.Notebook(parent)
        self.notebook.pack(fill=tk.BOTH, expand=True)
        self.notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed)

        # 検索バー(非表示状態で配置)
        self.search_bar = tk.Frame(parent, bg="#252526", pady=4)
        tk.Label(self.search_bar, text="検索:", bg="#252526",
                 fg="#ccc", font=("Arial", 9)).pack(side=tk.LEFT, padx=4)
        self.search_entry = ttk.Entry(self.search_bar, width=20)
        self.search_entry.pack(side=tk.LEFT, padx=2)
        tk.Label(self.search_bar, text="置換:", bg="#252526",
                 fg="#ccc", font=("Arial", 9)).pack(side=tk.LEFT, padx=4)
        self.replace_entry = ttk.Entry(self.search_bar, width=20)
        self.replace_entry.pack(side=tk.LEFT, padx=2)
        ttk.Button(self.search_bar, text="次へ",
                   command=self._find_next).pack(side=tk.LEFT, padx=2)
        ttk.Button(self.search_bar, text="置換",
                   command=self._replace_one).pack(side=tk.LEFT, padx=2)
        ttk.Button(self.search_bar, text="全置換",
                   command=self._replace_all).pack(side=tk.LEFT, padx=2)
        ttk.Button(self.search_bar, text="✕",
                   command=self._hide_search).pack(side=tk.LEFT, padx=4)
        self._search_visible = False

    def _build_terminal(self, parent):
        hdr = tk.Frame(parent, bg="#1a1a2e")
        hdr.pack(fill=tk.X)
        tk.Label(hdr, text="ターミナル", bg="#1a1a2e",
                 fg="#4fc3f7", font=("Arial", 9, "bold")).pack(
            side=tk.LEFT, padx=6, pady=2)
        ttk.Button(hdr, text="クリア",
                   command=self._clear_terminal).pack(side=tk.RIGHT, padx=4)

        self.terminal = tk.Text(parent, bg="#0d1117", fg="#d4d4d4",
                                 font=("Courier New", 9), relief=tk.FLAT,
                                 state=tk.DISABLED, wrap=tk.WORD)
        tsb = ttk.Scrollbar(parent, command=self.terminal.yview)
        self.terminal.configure(yscrollcommand=tsb.set)
        tsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.terminal.pack(fill=tk.BOTH, expand=True)

        self.terminal.tag_configure("err",  foreground="#f48771")
        self.terminal.tag_configure("info", foreground="#4fc3f7")
        self.terminal.tag_configure("ok",   foreground="#4ec9b0")

    # ── タブ管理 ──────────────────────────────────────────────────

    def _new_tab(self, path=None, content=None):
        frame = tk.Frame(self.notebook, bg="#1e1e1e")

        # 行番号キャンバス + エディタ
        code_area = tk.Frame(frame, bg="#1e1e1e")
        code_area.pack(fill=tk.BOTH, expand=True)

        line_canvas = tk.Canvas(code_area, width=40, bg="#0d1117",
                                 highlightthickness=0)
        line_canvas.pack(side=tk.LEFT, fill=tk.Y)

        editor = tk.Text(code_area, bg="#0d1117", fg="#d4d4d4",
                          font=("Courier New", 11), relief=tk.FLAT,
                          insertbackground="#fff",
                          selectbackground="#264f78",
                          undo=True, wrap=tk.NONE, tabs=("4m",))
        ysb = ttk.Scrollbar(code_area, command=editor.yview)
        xsb = ttk.Scrollbar(frame, orient=tk.HORIZONTAL,
                             command=editor.xview)
        editor.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
        ysb.pack(side=tk.RIGHT, fill=tk.Y)
        editor.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        xsb.pack(fill=tk.X)

        # 初期コンテンツ
        initial = content if content is not None else self.SAMPLE_CODE
        editor.insert("1.0", initial)

        label = os.path.basename(path) if path else self.TAB_UNTITLED
        self.notebook.add(frame, text=f"  {label}  ")
        tab_id = frame

        self._tabs[id(tab_id)] = {
            "path": path,
            "editor": editor,
            "line_canvas": line_canvas,
            "modified": False,
            "frame": frame,
        }

        # イベント
        def on_key(e, tid=id(tab_id)):
            info = self._tabs.get(tid)
            if info and not info["modified"]:
                info["modified"] = True
                idx = self.notebook.index(frame)
                cur_text = self.notebook.tab(idx, "text")
                if not cur_text.startswith("●"):
                    self.notebook.tab(idx, text="● " + cur_text.strip())
            self._update_line_numbers(tid)
            self._highlight(tid)
            self._update_cursor_pos(e.widget)

        editor.bind("<KeyRelease>", on_key)
        editor.bind("<ButtonRelease-1>",
                    lambda e: self._update_cursor_pos(e.widget))

        self.notebook.select(frame)
        self._update_line_numbers(id(tab_id))
        self._highlight(id(tab_id))

    def _current_tab(self):
        try:
            frame = self.notebook.nametowidget(self.notebook.select())
            return id(frame), self._tabs.get(id(frame))
        except Exception:
            return None, None

    def _on_tab_changed(self, event=None):
        _, info = self._current_tab()
        if info:
            path = info["path"] or self.TAB_UNTITLED
            self.status_var.set(path)

    def _close_tab(self):
        tid, info = self._current_tab()
        if info is None:
            return
        if info["modified"]:
            name = os.path.basename(info["path"]) if info["path"] else self.TAB_UNTITLED
            ans = messagebox.askyesnocancel("保存確認",
                                             f"「{name}」は変更されています。保存しますか?")
            if ans is None:
                return
            if ans:
                self._save_file()
        frame = info["frame"]
        self.notebook.forget(frame)
        del self._tabs[tid]

    # ── ファイル操作 ──────────────────────────────────────────────

    def _open_file(self):
        path = filedialog.askopenfilename(
            filetypes=[("Python", "*.py"), ("テキスト", "*.txt"),
                       ("すべて", "*.*")])
        if not path:
            return
        try:
            with open(path, encoding="utf-8") as f:
                content = f.read()
        except Exception as e:
            messagebox.showerror("エラー", str(e))
            return
        self._new_tab(path=path, content=content)

    def _save_file(self):
        tid, info = self._current_tab()
        if info is None:
            return
        if info["path"] is None:
            self._save_as()
            return
        self._do_save(tid, info, info["path"])

    def _save_as(self):
        tid, info = self._current_tab()
        if info is None:
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".py",
            filetypes=[("Python", "*.py"), ("テキスト", "*.txt"),
                       ("すべて", "*.*")])
        if not path:
            return
        self._do_save(tid, info, path)

    def _do_save(self, tid, info, path):
        content = info["editor"].get("1.0", tk.END)
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write(content)
        except Exception as e:
            messagebox.showerror("エラー", str(e))
            return
        info["path"] = path
        info["modified"] = False
        idx = self.notebook.index(info["frame"])
        self.notebook.tab(idx, text=f"  {os.path.basename(path)}  ")
        self.status_var.set(f"保存: {path}")

    # ── ファイルツリー ────────────────────────────────────────────

    def _open_folder(self):
        folder = filedialog.askdirectory()
        if folder:
            self._file_tree_path = folder
            self._refresh_file_tree()

    def _refresh_file_tree(self):
        folder = self._file_tree_path
        if not folder:
            return
        self.file_tree.delete(*self.file_tree.get_children())
        root_node = self.file_tree.insert(
            "", tk.END, text=os.path.basename(folder),
            values=[folder], open=True)
        try:
            for name in sorted(os.listdir(folder)):
                full = os.path.join(folder, name)
                if os.path.isfile(full):
                    self.file_tree.insert(root_node, tk.END,
                                           text=name, values=[full])
        except Exception:
            pass

    def _on_tree_double_click(self, event=None):
        sel = self.file_tree.selection()
        if not sel:
            return
        vals = self.file_tree.item(sel[0], "values")
        if not vals:
            return
        path = vals[0]
        if os.path.isfile(path):
            try:
                with open(path, encoding="utf-8") as f:
                    content = f.read()
                self._new_tab(path=path, content=content)
            except Exception as e:
                messagebox.showerror("エラー", str(e))

    # ── 実行 ──────────────────────────────────────────────────────

    def _run_code(self):
        _, info = self._current_tab()
        if info is None:
            return
        code = info["editor"].get("1.0", tk.END)
        path = info["path"]

        # 保存済みならそのファイルを実行、未保存なら一時保存
        if path and not info["modified"]:
            run_path = path
        else:
            import tempfile
            tmp = tempfile.NamedTemporaryFile(
                suffix=".py", delete=False, mode="w", encoding="utf-8")
            tmp.write(code)
            tmp.close()
            run_path = tmp.name

        self._term_write(f"▶ 実行: {os.path.basename(run_path)}\n", "info")
        self.run_status.config(text="実行中...")

        def target():
            try:
                py = self.python_path_var.get() or sys.executable
                self._proc = subprocess.Popen(
                    [py, run_path],
                    stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                    text=True, encoding="utf-8", errors="replace")
                stdout, stderr = self._proc.communicate(timeout=60)
                rc = self._proc.returncode
                if stdout:
                    self.root.after(0, self._term_write, stdout, "")
                if stderr:
                    self.root.after(0, self._term_write, stderr, "err")
                msg = f"✔ 終了 (コード {rc})" if rc == 0 else f"✖ エラー終了 (コード {rc})"
                tag = "ok" if rc == 0 else "err"
                self.root.after(0, self._term_write, msg + "\n", tag)
                self.root.after(0, self.run_status.config, {"text": ""})
            except subprocess.TimeoutExpired:
                self._proc.kill()
                self.root.after(0, self._term_write,
                                "⚠ タイムアウト (60秒)\n", "err")
            except Exception as e:
                self.root.after(0, self._term_write, str(e) + "\n", "err")
            finally:
                self._proc = None

        threading.Thread(target=target, daemon=True).start()

    def _stop_code(self):
        if self._proc:
            try:
                self._proc.kill()
            except Exception:
                pass
            self._term_write("⏹ 強制停止\n", "err")

    # ── ターミナル ────────────────────────────────────────────────

    def _term_write(self, text, tag=""):
        self.terminal.configure(state=tk.NORMAL)
        self.terminal.insert(tk.END, text, tag)
        self.terminal.see(tk.END)
        self.terminal.configure(state=tk.DISABLED)

    def _clear_terminal(self):
        self.terminal.configure(state=tk.NORMAL)
        self.terminal.delete("1.0", tk.END)
        self.terminal.configure(state=tk.DISABLED)

    # ── エディタ支援 ──────────────────────────────────────────────

    def _update_line_numbers(self, tid):
        info = self._tabs.get(tid)
        if not info:
            return
        editor = info["editor"]
        lc = info["line_canvas"]
        lc.delete("all")
        try:
            line_num = int(editor.index("@0,0").split(".")[0])
            while True:
                dline = editor.dlineinfo(f"{line_num}.0")
                if dline is None:
                    break
                lc.create_text(36, dline[1] + dline[3] // 2,
                                text=str(line_num), anchor="e",
                                fill="#858585",
                                font=("Courier New", 11))
                line_num += 1
        except Exception:
            pass

    def _highlight(self, tid):
        info = self._tabs.get(tid)
        if not info:
            return
        editor = info["editor"]

        for tag in ("kw", "str_", "cmt", "num", "deco", "bi"):
            editor.tag_remove(tag, "1.0", tk.END)
        editor.tag_configure("kw",   foreground="#569cd6")
        editor.tag_configure("str_", foreground="#ce9178")
        editor.tag_configure("cmt",  foreground="#6a9955")
        editor.tag_configure("num",  foreground="#b5cea8")
        editor.tag_configure("deco", foreground="#dcdcaa")
        editor.tag_configure("bi",   foreground="#4ec9b0")

        content = editor.get("1.0", tk.END)

        def apply(pattern, tag, flags=0):
            for m in re.finditer(pattern, content, flags):
                s = f"1.0 + {m.start()} chars"
                e = f"1.0 + {m.end()} chars"
                editor.tag_add(tag, s, e)

        apply(self.PYTHON_NUM,     "num")
        apply(self.PYTHON_STR,     "str_")
        apply(self.PYTHON_CMT,     "cmt")
        apply(self.PYTHON_DECO,    "deco")
        apply(self.PYTHON_BUILTINS,"bi")
        apply(self.PYTHON_KW,      "kw")

    def _update_cursor_pos(self, widget):
        try:
            pos = widget.index(tk.INSERT)
            line, col = pos.split(".")
            self.cursor_lbl.config(text=f"行 {line}, 列 {int(col)+1}")
        except Exception:
            pass

    # ── 検索・置換 ────────────────────────────────────────────────

    def _show_search(self):
        if not self._search_visible:
            self.search_bar.pack(fill=tk.X, before=self.notebook)
            self._search_visible = True
            self.search_entry.focus_set()

    def _hide_search(self):
        if self._search_visible:
            self.search_bar.pack_forget()
            self._search_visible = False

    def _find_next(self):
        _, info = self._current_tab()
        if not info:
            return
        editor = info["editor"]
        query = self.search_entry.get()
        if not query:
            return
        editor.tag_remove("search_hl", "1.0", tk.END)
        editor.tag_configure("search_hl", background="#4d4000",
                              foreground="#fff")
        start = editor.index(tk.INSERT)
        pos = editor.search(query, start, stopindex=tk.END)
        if not pos:
            pos = editor.search(query, "1.0", stopindex=tk.END)
        if pos:
            end_pos = f"{pos} + {len(query)} chars"
            editor.tag_add("search_hl", pos, end_pos)
            editor.mark_set(tk.INSERT, end_pos)
            editor.see(pos)

    def _replace_one(self):
        _, info = self._current_tab()
        if not info:
            return
        editor = info["editor"]
        query = self.search_entry.get()
        replacement = self.replace_entry.get()
        if not query:
            return
        pos = editor.search(query, tk.INSERT, stopindex=tk.END)
        if not pos:
            pos = editor.search(query, "1.0", stopindex=tk.END)
        if pos:
            end_pos = f"{pos} + {len(query)} chars"
            editor.delete(pos, end_pos)
            editor.insert(pos, replacement)

    def _replace_all(self):
        _, info = self._current_tab()
        if not info:
            return
        editor = info["editor"]
        query = self.search_entry.get()
        replacement = self.replace_entry.get()
        if not query:
            return
        content = editor.get("1.0", tk.END)
        count = content.count(query)
        if count:
            new_content = content.replace(query, replacement)
            editor.delete("1.0", tk.END)
            editor.insert("1.0", new_content)
            self.status_var.set(f"{count} 件置換しました")


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

5. コード解説

マルチタブ簡易IDEのコードを、実際に書かれている実装に沿って解説します。

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

App50 クラスにアプリの全機能をまとめています(メソッド28個・全619行)。__init__ ではタイトル「簡易IDE」とウィンドウサイズ 1200x905 を設定します。あわせて状態を保持する変数(self._procself._file_tree_path)を初期化し、そのあと _build_ui()_new_tab() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Menu×4・tk.Frame×11・tk.Button(ループで生成)・tk.Label×8・ttk.Entry×3・ttk.PanedWindow×2・ttk.Button×6・ttk.Treeviewttk.Scrollbar×4・ttk.Notebooktk.Text×2・tk.Canvas で構成しています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「…」ボタン(_open_folder())・「次へ」ボタン(_find_next())・「置換」ボタン(_replace_one())・「全置換」ボタン(_replace_all())・「✕」ボタン(_hide_search())・「クリア」ボタン(_clear_terminal())を command= で接続しています。ボタンはラベルと処理をペアで並べたデータをループで回して生成し、それぞれ _new_tab()_open_file()_save_file()_run_code()_stop_code()command= に渡しています。ボタンを増やしたいときはこのペアを1行足すだけで済みます。また bind("<Control-t>", ...) から _new_tab()bind("<Control-o>", ...) から _open_file()bind("<Control-s>", ...) から _save_file()bind("<Control-w>", ...) から _close_tab()bind("<F5>", ...) から _run_code()bind("<Double-1>", ...) から _on_tree_double_click()bind("<<NotebookTabChanged>>", ...) から _on_tab_changed()bind("<KeyRelease>", ...) から 処理、bind("<ButtonRelease-1>", ...) から _update_cursor_pos() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:TAB_UNTITLED = '無題'PYTHON_NUM = '\\b\\d+(\\.\\d+)?\\b'。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で Exceptionsubprocess.TimeoutExpired を捕捉しています。あわせて messagebox.askyesnocancel()messagebox.showerror() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

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

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

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

    新しいファイルを作成して app50.py と保存します。使うのは datetimeosresubprocesssystempfilethreadingtkinter だけなので、追加インストールは不要です。

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

    App50 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("簡易IDE")geometry("1200x905")configure(bg="#1e1e1e")minsize(1200, 905) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Menu×4・tk.Frame×11・tk.Button(ループで生成)・tk.Label×8・ttk.Entry×3・ttk.PanedWindow×2・ttk.Button×6・ttk.Treeviewttk.Scrollbar×4・ttk.Notebooktk.Text×2・tk.Canvas を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_open_folder()_find_next()_replace_one()_replace_all()_hide_search()_clear_terminal())につなぎます。ループ生成のボタンは、ラベルと処理(_new_tab()_open_file()_save_file()_run_code()_stop_code())をペアで並べたデータをループで回し、command= に渡して接続します。bind("<Control-t>", ...)bind("<Control-o>", ...)bind("<Control-s>", ...)bind("<Control-w>", ...)bind("<F5>", ...)bind("<Double-1>", ...)bind("<<NotebookTabChanged>>", ...)bind("<KeyRelease>", ...)bind("<ButtonRelease-1>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _term_write()(5行)・_new_tab()(60行)・_run_code()(48行)など を実装します。

  7. 7
    動作確認する

    python app50.py で起動し、各ボタンが反応すること・キー操作(<Control-t><Control-o><Control-s><Control-w><F5><KeyRelease>)が効くこと・マウス操作(クリック・ホイールなど)が効くことを確認します。

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

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

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

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

💡 クラス定数を書き換えて動きを変える

データはクラス定数(TAB_UNTITLEDPYTHON_KW)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 入力履歴機能

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

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

<Control-t> キーを押しても反応しない

原因:bind("<Control-t>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。

解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("1200x905") の固定サイズで起動するためです。

解決法:geometry()minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(1200, 905) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。「▶ 実行」は、いま開いているタブの中身を subprocess.Popen([py, run_path], ...)(440-443行)で Python として実行します。実行するのは、自分で中身を説明できるコードだけにしてください。ネットで拾ったコードや配布元の分からない .py を開いて実行しないでください。60秒でタイムアウトしますが(444行)、その前にファイルを消したり通信したりする処理は止められません。保存していないタブの中身は、一時フォルダに .py として書き出されます(427-432行)。パスワードや API キーを書いたまま「▶ 実行」を押さないでください。課題1・課題2に追加のライブラリは要りません。

  1. 課題1:実行するたびに一時ファイルが増えていく

    保存していないタブで「▶ 実行」を押すと、中身が一時フォルダの .py に書き出されます。この一時ファイルを消す処理はどこにもありません。押した回数だけ、コードが平文で残り続けます。

    期待結果:起動直後のサンプルコードのまま「▶ 実行」を押す。ターミナルに ▶ 実行: tmp で始まるファイル名が出て、Fibonacci: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]Sum: 88✔ 終了 (コード 0) と続く。同じ操作を3回くり返すと、ファイル名は毎回違う。python -c "import tempfile; print(tempfile.gettempdir())" が表示するフォルダを開くと、tmp で始まる .py が3つ残っている(他のアプリの一時ファイルも入っているので、更新時刻の新しいものを見てください)。直したあとは、実行が終わった時点で残らない。

    合格条件

    • 保存済みのファイルを開いて実行したときは、そのファイルを消さない
    • 保存していないコードを実行したあと、一時ファイルが残らない
    • 実行中に「⏹ 停止」を押したときも残らない

    ヒント①(どこを触るか): 触るのは2か所です。_run_code() の427-432行(一時ファイルを作る側)と、その下の finally:(460-461行)。
    ヒント②(使うもの): 424-425行の分岐で一時ファイルを作ったかどうかを変数に覚えておき、finally: の中で os.remove(run_path) を呼びます。os は3行目で読み込み済みです。消せないことがあるので try:except OSError: で囲みます。
    つまずきやすい点: run_pathtarget() の外で決まる変数なので、そのまま参照できます(432行)。消すのは communicate()(444行)が戻ってから=実行が終わったあとにしてください。「⏹ 停止」(465-471行)で kill() したときも communicate() は戻るので、finally: は通ります。delete=False を渡している以上、自分で消さないかぎり一時ファイルは残ったままです(428-429行)。

  2. 課題2:エクスプローラーにフォルダが1つも出てこない

    左のツリーは、選んだフォルダの直下にあるファイルだけを並べます。中にあるフォルダは飛ばされるため、1階層ぶんしか見えません。

    期待結果:ヘッダーの を押して、中にサブフォルダがあるフォルダを選ぶ。ツリーにはいちばん上のフォルダ名の行と、その直下のファイルだけが並び、サブフォルダの行は出ない。直したあとは、サブフォルダも行として並び、開くとその中のファイルが見える。ファイルをダブルクリックすると、今までどおり新しいタブで開く。

    合格条件

    • いちばん上のフォルダ名の行と、その直下のファイルの並びは今までどおり
    • 直下のサブフォルダが行として並ぶ
    • ファイルのダブルクリックでタブが開く動きは変わらない

    ヒント①(どこを触るか): 触るのは2か所です。_refresh_file_tree() の390-394行のループと、フォルダの中身を入れるメソッドを1つ追加。
    ヒント②(使うもの): いまのループは os.path.isfile(full) が真のときだけ insert() しています(392-394行)。os.path.isdir(full) のときも insert() し、その戻り値(親ノードの ID)を渡して同じ処理を呼べば入れ子になる形です。フォルダを先に並べたいときは、sorted()(390行)の結果を os.path.isdir で2つに分けてから回します。
    つまずきやすい点: values=[full] を付け忘れると、_on_tree_double_click()(398-412行)が vals 空で return します。フォルダの行をダブルクリックしても、406行の os.path.isfile(path) が偽なので何も起きません(案内も出ません)。深いフォルダを一度に全部たどると、開いた瞬間に固まることがあります。中を読むのは <<TreeviewOpen>> で開かれたときだけにする方法もあるので、大きいフォルダを扱うなら検討してください。読み取り権限が無いフォルダでは os.listdir()PermissionError を出すため、395行と同じ形で受け止めてください。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリにも挑戦してみましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。