マルチタブテキストエディタ
タブ切り替えで複数ファイルを同時編集できる高機能テキストエディタ。Notebookウィジェットによるタブ管理と未保存検知を学ぶ。
1. アプリ概要
タブ切り替えで複数ファイルを同時編集できる高機能テキストエディタ。Notebookウィジェットによるタブ管理と未保存検知を学ぶ。
このアプリはappカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter、難易度は ★★☆ 普通 です。
このアプリは「実用アプリ」カテゴリです。実用的なアプリの開発は、GUI とビジネスロジックの分離・データ永続化・エラー処理の全てを一度に学べる好機です。tkinter を活かして実装するこの構造は、他のアプリにも応用が効きます。
コピー&実行で動作確認 → コード解説で構造理解 → カスタマイズで応用、の 3 ステップで進めると、短時間で本質を押さえられます。
アプリを完成させた後は、自分の使い方に合わせて改造するのが学びを定着させる近道です。カスタマイズ章のアイデアを足がかりに、独自機能を一つ追加してみてください。
2. 機能一覧
- ループで生成するボタン群(それぞれ
_new_tab()・_open()・_save()を実行) - 「次へ」ボタン(
_find_next())・「置換」ボタン(_replace_one())・「全置換」ボタン(_replace_all())・「✕」ボタン(_hide_search())で操作 - キー
<Control-n>から_new_tab()を実行 - キー
<Control-o>から_open()を実行 - キー
<Control-s>から_save()を実行 - キー
<Control-w>から_close_tab()を実行 - キー
<Control-h>から_show_search()を実行 - イベント
<<NotebookTabChanged>>から_on_tab_changed()を実行 - キー
<KeyRelease>から 処理 を実行 - マウス操作
<ButtonRelease-1>から_update_stats()を実行 filedialog.askopenfilename()によるファイル選択ダイアログfiledialog.asksaveasfilename()によるファイル選択ダイアログmessagebox.askyesnocancel()・messagebox.showerror()によるダイアログ通知- ウィンドウはタイトル「テキストエディタ」・サイズ 1000x680 で起動
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. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import os
import re
class App059:
"""マルチタブテキストエディタ"""
def __init__(self, root):
self.root = root
self.root.title("テキストエディタ")
self.root.geometry("1000x680")
self.root.configure(bg="#1e1e1e")
self._tabs = {}
self._build_ui()
self._new_tab()
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_m = tk.Menu(menubar, tearoff=False, bg="#252526", fg="#ccc",
activebackground="#094771", activeforeground="#fff")
menubar.add_cascade(label="ファイル", menu=file_m)
file_m.add_command(label="新規 (Ctrl+N)", command=self._new_tab)
file_m.add_command(label="開く (Ctrl+O)", command=self._open)
file_m.add_command(label="保存 (Ctrl+S)", command=self._save)
file_m.add_command(label="名前を付けて保存", command=self._save_as)
file_m.add_separator()
file_m.add_command(label="タブを閉じる (Ctrl+W)", command=self._close_tab)
file_m.add_command(label="終了", command=self.root.quit)
edit_m = tk.Menu(menubar, tearoff=False, bg="#252526", fg="#ccc",
activebackground="#094771", activeforeground="#fff")
menubar.add_cascade(label="編集", menu=edit_m)
edit_m.add_command(label="検索/置換 (Ctrl+H)", command=self._show_search)
view_m = tk.Menu(menubar, tearoff=False, bg="#252526", fg="#ccc",
activebackground="#094771", activeforeground="#fff")
menubar.add_cascade(label="表示", menu=view_m)
view_m.add_command(label="行番号 ON/OFF", command=self._toggle_lineno)
view_m.add_command(label="ワードラップ切替", command=self._toggle_wrap)
self.root.bind("<Control-n>", lambda e: self._new_tab())
self.root.bind("<Control-o>", lambda e: self._open())
self.root.bind("<Control-s>", lambda e: self._save())
self.root.bind("<Control-w>", lambda e: self._close_tab())
self.root.bind("<Control-h>", lambda e: self._show_search())
# ツールバー
tb = tk.Frame(self.root, bg="#2d2d2d", pady=4)
tb.pack(fill=tk.X)
for text, cmd in [("📄 新規", self._new_tab), ("📂 開く", self._open),
("💾 保存", self._save)]:
tk.Button(tb, 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(tb, text="エンコード:", bg="#2d2d2d", fg="#888",
font=("Arial", 9)).pack(side=tk.LEFT, padx=(12, 4))
self.enc_var = tk.StringVar(value="utf-8")
ttk.Combobox(tb, textvariable=self.enc_var,
values=["utf-8", "utf-8-sig", "shift-jis", "cp932"],
state="readonly", width=10).pack(side=tk.LEFT)
self.cursor_lbl = tk.Label(tb, text="行 1, 列 1", bg="#2d2d2d",
fg="#888", font=("Arial", 9))
self.cursor_lbl.pack(side=tk.RIGHT, padx=8)
self.char_lbl = tk.Label(tb, text="0 文字", bg="#2d2d2d",
fg="#888", font=("Arial", 9))
self.char_lbl.pack(side=tk.RIGHT, padx=8)
# 検索バー(非表示)
self.search_bar = tk.Frame(self.root, 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_var = tk.StringVar()
ttk.Entry(self.search_bar, textvariable=self.search_var,
width=20).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_var = tk.StringVar()
ttk.Entry(self.search_bar, textvariable=self.replace_var,
width=20).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
# ノートブック
self.notebook = ttk.Notebook(self.root)
self.notebook.pack(fill=tk.BOTH, expand=True)
self.notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed)
# ステータスバー
self.status_var = tk.StringVar(value="準備完了")
self._show_lineno = True
self._wrap_mode = tk.NONE
tk.Label(self.root, textvariable=self.status_var,
bg="#007acc", fg="#fff", font=("Arial", 8),
anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)
# ── タブ管理 ──────────────────────────────────────────────────
def _new_tab(self, path=None, content=""):
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)
text = tk.Text(code_area, bg="#0d1117", fg="#d4d4d4",
font=("Courier New", 11), relief=tk.FLAT,
insertbackground="#fff",
selectbackground="#264f78",
undo=True, wrap=self._wrap_mode, tabs=("4m",))
ysb = ttk.Scrollbar(code_area, command=text.yview)
xsb = ttk.Scrollbar(frame, orient=tk.HORIZONTAL, command=text.xview)
text.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
ysb.pack(side=tk.RIGHT, fill=tk.Y)
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
xsb.pack(fill=tk.X)
if content:
text.insert("1.0", content)
label = os.path.basename(path) if path else "無題"
self.notebook.add(frame, text=f" {label} ")
tid = id(frame)
self._tabs[tid] = {
"path": path, "editor": text,
"line_canvas": line_canvas,
"modified": bool(content and not path),
"frame": frame,
}
def on_key(e, t=tid):
info = self._tabs.get(t)
if info and not info["modified"]:
info["modified"] = True
idx = self.notebook.index(info["frame"])
cur = self.notebook.tab(idx, "text")
if not cur.startswith("●"):
self.notebook.tab(idx, text="● " + cur.strip())
self._update_lineno(t)
self._update_stats(text)
text.bind("<KeyRelease>", on_key)
text.bind("<ButtonRelease-1>", lambda e: self._update_stats(text))
self.notebook.select(frame)
self._update_lineno(tid)
def _current(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()
if info:
self.status_var.set(info["path"] or "無題")
def _close_tab(self):
tid, info = self._current()
if not info:
return
if info["modified"]:
name = os.path.basename(info["path"]) if info["path"] else "無題"
ans = messagebox.askyesnocancel("保存確認",
f"「{name}」の変更を保存しますか?")
if ans is None:
return
if ans:
self._save()
frame = info["frame"]
self.notebook.forget(frame)
del self._tabs[tid]
# ── ファイル操作 ──────────────────────────────────────────────
def _open(self):
path = filedialog.askopenfilename(
filetypes=[("テキスト", "*.txt *.py *.md *.csv *.json *.html *.css *.js"),
("すべて", "*.*")])
if not path:
return
try:
enc = self.enc_var.get()
with open(path, encoding=enc, errors="replace") as f:
content = f.read()
self._new_tab(path=path, content=content)
_, info = self._current()
if info:
info["modified"] = False
except Exception as e:
messagebox.showerror("エラー", str(e))
def _save(self):
tid, info = self._current()
if not info:
return
if not info["path"]:
self._save_as()
return
self._do_save(tid, info, info["path"])
def _save_as(self):
tid, info = self._current()
if not info:
return
path = filedialog.asksaveasfilename(
defaultextension=".txt",
filetypes=[("テキスト", "*.txt"), ("Python", "*.py"),
("すべて", "*.*")])
if path:
self._do_save(tid, info, path)
def _do_save(self, tid, info, path):
try:
enc = self.enc_var.get()
with open(path, "w", encoding=enc) as f:
f.write(info["editor"].get("1.0", tk.END))
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}")
except Exception as e:
messagebox.showerror("エラー", str(e))
# ── 表示 ──────────────────────────────────────────────────────
def _toggle_lineno(self):
self._show_lineno = not self._show_lineno
_, info = self._current()
if info:
if self._show_lineno:
info["line_canvas"].pack(before=info["editor"], side=tk.LEFT, fill=tk.Y)
else:
info["line_canvas"].pack_forget()
def _toggle_wrap(self):
self._wrap_mode = tk.WORD if self._wrap_mode == tk.NONE else tk.NONE
_, info = self._current()
if info:
info["editor"].configure(wrap=self._wrap_mode)
def _update_lineno(self, tid):
info = self._tabs.get(tid)
if not info or not self._show_lineno:
return
editor = info["editor"]
lc = info["line_canvas"]
lc.delete("all")
try:
ln = int(editor.index("@0,0").split(".")[0])
while True:
dl = editor.dlineinfo(f"{ln}.0")
if dl is None:
break
lc.create_text(36, dl[1] + dl[3]//2,
text=str(ln), anchor="e",
fill="#858585", font=("Courier New", 11))
ln += 1
except Exception:
pass
def _update_stats(self, editor):
try:
pos = editor.index(tk.INSERT).split(".")
self.cursor_lbl.config(text=f"行 {pos[0]}, 列 {int(pos[1])+1}")
chars = len(editor.get("1.0", tk.END)) - 1
self.char_lbl.config(text=f"{chars:,} 文字")
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
def _hide_search(self):
if self._search_visible:
self.search_bar.pack_forget()
self._search_visible = False
def _get_editor(self):
_, info = self._current()
return info["editor"] if info else None
def _find_next(self):
editor = self._get_editor()
if not editor:
return
query = self.search_var.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 = f"{pos} + {len(query)} chars"
editor.mark_set(tk.INSERT, end)
editor.see(pos)
editor.tag_remove("sel", "1.0", tk.END)
editor.tag_add("sel", pos, end)
def _replace_one(self):
editor = self._get_editor()
if not editor:
return
query = self.search_var.get()
rep = self.replace_var.get()
if not query:
return
pos = editor.search(query, "1.0", stopindex=tk.END)
if pos:
end = f"{pos} + {len(query)} chars"
editor.delete(pos, end)
editor.insert(pos, rep)
def _replace_all(self):
editor = self._get_editor()
if not editor:
return
query = self.search_var.get()
rep = self.replace_var.get()
if not query:
return
content = editor.get("1.0", tk.END)
count = content.count(query)
if count:
editor.delete("1.0", tk.END)
editor.insert("1.0", content.replace(query, rep))
self.status_var.set(f"{count} 件置換")
if __name__ == "__main__":
root = tk.Tk()
app = App059(root)
root.mainloop()
5. コード解説
マルチタブテキストエディタのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App059 クラスにアプリの全機能をまとめています(メソッド20個・全354行)。__init__ ではタイトル「テキストエディタ」とウィンドウサイズ 1000x680 を設定します。そのあと _build_ui() → _new_tab() の順に呼び出して初期状態を作ります。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Menu×4・tk.Frame×4・tk.Button(ループで生成)・tk.Label×6・ttk.Combobox・ttk.Entry×2・ttk.Button×4・ttk.Notebook・tk.Canvas・tk.Text・ttk.Scrollbar×2 で構成しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「次へ」ボタン(_find_next())・「置換」ボタン(_replace_one())・「全置換」ボタン(_replace_all())・「✕」ボタン(_hide_search())を command= で接続しています。ボタンはラベルと処理をペアで並べたデータをループで回して生成し、それぞれ _new_tab()・_open()・_save() を command= に渡しています。ボタンを増やしたいときはこのペアを1行足すだけで済みます。また bind("<Control-n>", ...) から _new_tab()、bind("<Control-o>", ...) から _open()、bind("<Control-s>", ...) から _save()、bind("<Control-w>", ...) から _close_tab()、bind("<Control-h>", ...) から _show_search()、bind("<<NotebookTabChanged>>", ...) から _on_tab_changed()、bind("<KeyRelease>", ...) から 処理、bind("<ButtonRelease-1>", ...) から _update_stats() を呼べるようにイベントも登録しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception を捕捉しています。あわせて messagebox.askyesnocancel()・messagebox.showerror() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app059.py と保存します。使うのは
os・re・tkinterだけなので、追加インストールは不要です。 -
2クラスの骨格を作る
App059クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("テキストエディタ")・geometry("1000x680")・configure(bg="#1e1e1e")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Menu×4・tk.Frame×4・tk.Button(ループで生成)・tk.Label×6・ttk.Combobox・ttk.Entry×2・ttk.Button×4・ttk.Notebook・tk.Canvas・tk.Text・ttk.Scrollbar×2 を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_find_next()・_replace_one()・_replace_all()・_hide_search())につなぎます。ループ生成のボタンは、ラベルと処理(_new_tab()・_open()・_save())をペアで並べたデータをループで回し、command=に渡して接続します。bind("<Control-n>", ...)・bind("<Control-o>", ...)・bind("<Control-s>", ...)・bind("<Control-w>", ...)・bind("<Control-h>", ...)・bind("<<NotebookTabChanged>>", ...)・bind("<KeyRelease>", ...)・bind("<ButtonRelease-1>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_new_tab()(49行)・_open()(16行)・_find_next()(16行)など を実装します。 -
7動作確認する
python app059.pyで起動し、各ボタンが反応すること・キー操作(<Control-n>・<Control-o>・<Control-s>・<Control-w>・<Control-h>・<KeyRelease>)が効くこと・マウス操作(クリック・ホイールなど)が効くことを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 配色をまとめて管理する
背景色 #1e1e1e をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。
💡 設定ダイアログ
フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。
8. よくある問題と解決法
❌ <Control-n> キーを押しても反応しない
原因:bind("<Control-n>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。
解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("1000x680") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。「💾 保存」は、一度開いたファイルを確認なしでその場に上書きします(213-216行)。練習は、消えても困らないコピーを別のフォルダに作って行ってください。文字コードのコンボボックスは、開くときと保存するときの両方で使われます(199行・231行)。開いたときと違う値のまま保存すると、元の文字が失われることがあります。課題1・課題2に追加のライブラリは要りません。
-
課題1:「置換」を押すと、選んだ場所ではなく先頭が書き換わる
検索バーの「次へ」は、見つかった場所を選択しながら順に進みます。ところが「置換」は選択の位置を見ず、毎回1件目を探し直しています。
期待結果:新しいタブに
abc abc abcと打つ。Ctrl+H で検索バーを出し、検索欄にabc・置換欄にxyzを入れて「次へ」を2回押すと、2つ目のabcが選ばれる。ここで「置換」を押すとxyz abc abcになり、選んでいない1つ目が書き換わる。直したあとはabc xyz abcになる。合格条件
- 「次へ」で選んだ場所が置き換わる
- 何も選んでいないときは、今までどおり最初に見つかった場所を置き換える
- 「全置換」の結果は変わらない
ヒント①(どこを触るか): 触るのは1か所です。
_replace_one()の329行、editor.search(query, "1.0", stopindex=tk.END)の行。
ヒント②(使うもの):editor.tag_ranges("sel")が選択範囲を返します。選択があり、その中身が検索語と同じなら、そこがdelete()/insert()の対象です(332-333行の書き方をそのまま使えます)。選択が無いときは、今までどおり"1.0"から探します。
つまずきやすい点:_find_next()は一致の末尾にINSERTを動かしてからselを付けます(316-319行)。tag_ranges()の戻り値は、選択が無ければ空のタプル、あれば開始と終了の2要素です。str()を通すと1.4のような文字列インデックスになります。置換語に検索語を含めると(abcをabcdに置換)、同じ場所が何度でも見つかります。 -
課題2:置換した直後にタブを閉じると、保存を聞かれずに消える
タブ名の
●は、キーを離したときにだけ付きます。「置換」「全置換」はコードから中身を書き換えるので、印が付きません。未保存のまま閉じても確認が出ず、置換した内容はそのまま消えます。期待結果:新しいタブに
abc abc abcと打ち、「💾 保存」でtest.txtとして保存する(タブ名がtest.txtに変わり●が消える)。Ctrl+H で検索欄にabc・置換欄にxyzを入れて「全置換」を押すと、ステータスバーは3 件置換になるのにタブ名は変わらない。Ctrl+W を押すと、保存を聞かれずにタブが閉じ、ファイルはabc abc abcのまま残る。直したあとは、置換した時点でタブ名が● test.txtになり、Ctrl+W で保存確認が出る。合格条件
- キーを打って変えたときの
●は今までどおり付く - 「置換」「全置換」で中身が変わったときも
●が付く - 印が付いた状態でタブを閉じると、保存するか聞かれる
ヒント①(どこを触るか): 触るのは3か所です。
_replace_one()の置換のあと(333行の直後)、_replace_all()の置換のあと(347行の直後)、そして印を付けるメソッドを1つ追加。
ヒント②(使うもの): 印を付ける処理はon_keyの中にあります(146-153行)。info["modified"] = Trueにしてからself.notebook.tab(idx, text="● " + cur.strip())を呼ぶ流れをメソッドへ移し、置換のあとで呼び出してください。いまのタブは_current()(162-167行)で取れます。
つまずきやすい点:on_keyは_new_tab()の中で定義され、tidを既定引数で閉じ込めています(146行)。メソッドへ移すときはtidを引数で渡してください。cur.startswith("●")の判定を残さないと、押すたびに●が増えます(152-153行)。_replace_all()はcountが0だと何もしないので、そのときは印も付けない形にそろえます(345行)。 - キーを打って変えたときの
-
課題3:新しいボタンを追加する
既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と
command=の接続がセットで身に付きます。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。