初心者向け No.08

シンプルテキストエディタ

ファイルの開く・保存・新規作成ができるテキストエディタ。メニューバーとファイルダイアログの実装を学びます。

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

1. アプリ概要

ファイルの開く・保存・新規作成ができるテキストエディタ。メニューバーとファイルダイアログの実装を学びます。

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

このアプリは「ツール」カテゴリです。日々の作業を自動化する実用ツールは Python が最も得意とする領域です。短いコードで実用性のある成果物が作れる点が魅力です。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。

カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。

シンプルテキストエディタ 起動時の画面
起動時の画面
シンプルテキストエディタ 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群(それぞれ new_file()open_file()save_file() を実行)
  • キー <KeyRelease> から _update_status() を実行
  • マウス操作 <ButtonRelease> から _update_status() を実行
  • キー <Control-n> から new_file() を実行
  • キー <Control-o> から open_file() を実行
  • キー <Control-s> から save_file() を実行
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.askyesno() によるダイアログ通知
  • ウィンドウはタイトル「テキストエディタ - 無題」・サイズ 843x512 で起動

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. 完全なソースコード

💡
コードのコピー方法

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

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


class App08:
    """シンプルテキストエディタ"""

    def __init__(self, root):
        self.root = root
        self.root.title("テキストエディタ - 無題")
        self.root.geometry("843x512")
        self.root.minsize(843, 512)
        self.root.configure(bg="#f8f9fc")
        self.current_file = None
        self._build_ui()

    def _build_ui(self):
        # メニューバー
        menubar = tk.Menu(self.root)
        self.root.config(menu=menubar)

        file_menu = tk.Menu(menubar, tearoff=0)
        menubar.add_cascade(label="ファイル", menu=file_menu)
        file_menu.add_command(label="新規作成", accelerator="Ctrl+N", command=self.new_file)
        file_menu.add_command(label="開く...", accelerator="Ctrl+O", command=self.open_file)
        file_menu.add_command(label="上書き保存", accelerator="Ctrl+S", command=self.save_file)
        file_menu.add_command(label="名前を付けて保存...", command=self.save_as)
        file_menu.add_separator()
        file_menu.add_command(label="終了", command=self.root.quit)

        edit_menu = tk.Menu(menubar, tearoff=0)
        menubar.add_cascade(label="編集", menu=edit_menu)
        edit_menu.add_command(label="元に戻す", accelerator="Ctrl+Z",
                              command=lambda: self.text.event_generate("<<Undo>>"))
        edit_menu.add_command(label="やり直し", accelerator="Ctrl+Y",
                              command=lambda: self.text.event_generate("<<Redo>>"))
        edit_menu.add_separator()
        edit_menu.add_command(label="全て選択", accelerator="Ctrl+A",
                              command=lambda: self.text.tag_add(tk.SEL, "1.0", tk.END))

        # ツールバー
        toolbar = tk.Frame(self.root, bg="#e8eaf0", pady=4)
        toolbar.pack(fill=tk.X)
        for label, cmd in [("新規", self.new_file), ("開く", self.open_file),
                           ("保存", self.save_file)]:
            ttk.Button(toolbar, text=label, command=cmd, width=6).pack(side=tk.LEFT, padx=2)

        # テキストエリア
        text_frame = tk.Frame(self.root)
        text_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        self.text = tk.Text(text_frame, font=("Courier New", 12), wrap=tk.WORD,
                            undo=True, bg="white", fg="#111",
                            insertbackground="#3776ab", padx=8, pady=8)
        self.text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scrollbar = ttk.Scrollbar(text_frame, command=self.text.yview)
        self.text.configure(yscrollcommand=scrollbar.set)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        # ステータスバー
        self.status_var = tk.StringVar(value="行: 1  列: 1  文字数: 0")
        tk.Label(self.root, textvariable=self.status_var, anchor="w",
                 bg="#e8eaf0", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

        self.text.bind("<KeyRelease>", self._update_status)
        self.text.bind("<ButtonRelease>", self._update_status)

        # キーボードショートカット
        self.root.bind("<Control-n>", lambda e: self.new_file())
        self.root.bind("<Control-o>", lambda e: self.open_file())
        self.root.bind("<Control-s>", lambda e: self.save_file())

    def _update_status(self, event=None):
        pos = self.text.index(tk.INSERT)
        line, col = pos.split(".")
        chars = len(self.text.get("1.0", tk.END)) - 1
        self.status_var.set(f"行: {line}  列: {int(col)+1}  文字数: {chars}")

    def new_file(self):
        if self.text.get("1.0", tk.END).strip():
            if not messagebox.askyesno("確認", "変更を破棄して新規作成しますか?"):
                return
        self.text.delete("1.0", tk.END)
        self.current_file = None
        self.root.title("テキストエディタ - 無題")

    def open_file(self):
        path = filedialog.askopenfilename(
            filetypes=[("テキストファイル", "*.txt"), ("全てのファイル", "*.*")])
        if not path:
            return
        with open(path, encoding="utf-8") as f:
            content = f.read()
        self.text.delete("1.0", tk.END)
        self.text.insert("1.0", content)
        self.current_file = path
        self.root.title(f"テキストエディタ - {path}")

    def save_file(self):
        if self.current_file:
            with open(self.current_file, "w", encoding="utf-8") as f:
                f.write(self.text.get("1.0", tk.END))
        else:
            self.save_as()

    def save_as(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".txt",
            filetypes=[("テキストファイル", "*.txt"), ("全てのファイル", "*.*")])
        if not path:
            return
        with open(path, "w", encoding="utf-8") as f:
            f.write(self.text.get("1.0", tk.END))
        self.current_file = path
        self.root.title(f"テキストエディタ - {path}")


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

5. コード解説

シンプルテキストエディタのコードを、実際に書かれている実装に沿って解説します。

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

App08 クラスにアプリの全機能をまとめています(メソッド7個・全121行)。__init__ ではタイトル「テキストエディタ - 無題」とウィンドウサイズ 843x512 を設定します。あわせて状態を保持する変数(self.current_file)を初期化し、最後に _build_ui() で画面を組み立てます。

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

ウィジェット構成

画面は tk.Menu×3・tk.Frame×2・ttk.Button(ループで生成)・tk.Textttk.Scrollbartk.Label で構成しています。

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

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

ボタンはラベルと処理をペアで並べたデータをループで回して生成し、それぞれ new_file()open_file()save_file()command= に渡しています。ボタンを増やしたいときはこのペアを1行足すだけで済みます。また bind("<KeyRelease>", ...) から _update_status()bind("<ButtonRelease>", ...) から _update_status()bind("<Control-n>", ...) から new_file()bind("<Control-o>", ...) から open_file()bind("<Control-s>", ...) から save_file() を呼べるようにイベントも登録しています。

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

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

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

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

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

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

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

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

    title("テキストエディタ - 無題")geometry("843x512")configure(bg="#f8f9fc")minsize(843, 512) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

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

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

    ループ生成のボタンは、ラベルと処理(new_file()open_file()save_file())をペアで並べたデータをループで回し、command= に渡して接続します。bind("<KeyRelease>", ...)bind("<ButtonRelease>", ...)bind("<Control-n>", ...)bind("<Control-o>", ...)bind("<Control-s>", ...) の登録も忘れずに。

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

    アプリの本体である open_file()(11行)・new_file()(7行)・save_file()(6行)・_update_status()(5行) を実装します。

  7. 7
    動作確認する

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

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

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

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

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

💡 配色をまとめて管理する

背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。

💡 入力履歴機能

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

💡「開く」・×ボタンでも保存を確認する

今の実装は「新規作成」を選んだときだけ askyesno で未保存の変更を確認します。「開く」を選ぶと確認なしに本文が上書きされ、ウィンドウ右上の×ボタンで閉じる場合も同様に確認を素通りしてしまいます。open_file() の冒頭に new_file() と同じ確認処理を足し、root.protocol("WM_DELETE_WINDOW", ...) でハンドラを登録して askyesnocancel で「保存する/しない/キャンセル」の3択にすると、どちらの操作でも変更を守れます。練習問題として自分で実装してみてください。

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

<KeyRelease> キーを押しても反応しない

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

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

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

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

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

❌ 保存を押しても保存されない・エラーになる

原因:書き込み権限のないフォルダ(システム配下など)を保存先に選んだり、外付けドライブが外れていたりすると、open(path, "w")OSErrorPermissionErrorを含む)を送出します。

解決法:保存処理を try/except OSError で囲み、失敗したら messagebox.showerror で原因を表示してから保存先を選び直せるようにしてください。デスクトップや書類フォルダなど、自分に書き込み権限があるフォルダを保存先に選べば大半は解決します。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。

  1. 課題1:ステータスバーに総行数を足す

    ステータスバーの「行 / 列 / 文字数」に「総行数」を追加します。文字を打つたびに数値が変わるようにしてください。

    期待結果:「abc」「def」「ghi」と3行入力すると、ステータスバーが 行: 3 列: 4 文字数: 11 総行数: 3 になる。全部消すと 総行数: 1 に戻る。

    合格条件

    • キーを離すたびに総行数が変わる(ボタンを押さなくても更新される)
    • 改行を1つ消すと総行数が1つ減る
    • 何も入力していない状態で「総行数: 1」(0 ではない)

    ヒント①(どこを触るか): 表示文字列を組み立てているのは _update_status()self.status_var.set(f"…") です。あわせて _build_ui() の初期値 tk.StringVar(value="行: 1 列: 1 文字数: 0") も直してください(__init__()_update_status() を呼ばないので、最初のキー入力まではこの初期値が表示されます)。
    ヒント②(使うもの): 最終行の行番号は self.text.index("end-1c") が返す「行.列」の左側。split(".") で取り出して int() にします。
    つまずきやすい点: self.text.get("1.0", tk.END) の末尾には必ず改行が1つ余分に付きます(文字数の計算が - 1 している理由)。split("\n") の要素数で行を数えると常に1行多くなります。

  2. 課題2:未保存の変更をタイトルバーに「*」で示す

    本文を編集したらウィンドウタイトルの末尾に「*」を付け、保存・新規作成・ファイルを開いた直後は消えるようにします。

    期待結果:起動直後は「テキストエディタ - 無題」。1文字打つと「テキストエディタ - 無題 *」に変わり、Ctrl+S で保存すると「*」が消える。

    合格条件

    • 保存したあとにもう一度編集すると「*」がまた付く
    • 「開く」で読み込んだ直後は「*」が付かない
    • タイトルのファイル名部分(self.current_file の表示)は今までどおり

    ヒント①(どこを触るか): 編集の検知は _build_ui()bind をまとめている箇所に1行足します。タイトルを戻すのは new_file() / open_file() / save_file() / save_as() の4つです。
    ヒント②(使うもの): Text ウィジェットは変更フラグを持っていて、self.text.edit_modified() で状態を読み、self.text.edit_modified(False) で戻せます。イベント名は仮想イベントの <<Modified>> です。
    つまずきやすい点: edit_modified(False) を呼ぶと <<Modified>> がもう一度発火します(Python 3.12 / Tk 8.6 で確認したところ、1文字の入力でハンドラが2回呼ばれました)。2回目で何もしない書き方にしておかないと処理が往復します。

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

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

🚀
次に挑戦するアプリ

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

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

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

📖
独学を1冊で体系化するなら

写経しながら「なぜこう書くのか」が気になり始めたら、入門書を1冊通して読むと断片的な知識がつながります。Python入門書のおすすめ2冊(当サイトの参考書ランキング総合1〜2位)で、独学者向けの最初の1冊を比較しています。