파이썬 코드로 “텍스트 에디터” 만들기 — 교육용 실습 가이드
파이썬으로 텍스트 에디터를 만드는 것은 GUI 프로그래밍의 기초를 배우기에 아주 좋은 주제입니다. 이 글은 수업·동아리·개인 프로젝트에서 바로 활용할 수 있는 완결형 실습 포스팅으로, tkinter 기반의 미니 에디터부터 PyQt6 버전까지 단계별 예제를 제공합니다. 파일 열기/저장, 되돌리기, 찾기/바꾸기, 줄/열 표시와 같은 필수 기능을 짚으며 코드 설계 관점도 함께 정리합니다.
본 포스팅은 순수 파이썬 표준 라이브러리(tkinter)로 시작해, 선택적으로 PyQt6 확장 예제를 제공합니다. 설치와 실행은 Windows, macOS, Linux에서 모두 가능합니다.
왜 파이썬으로 에디터를 만들까?
에디터는 사용자 입력, 파일 I/O, 단축키, 메뉴, 상태 표시 등 GUI 앱의 핵심 요소를 모두 담고 있습니다. 파이썬의 간결한 문법과 풍부한 GUI 프레임워크 덕분에 학습 장벽이 낮고, 실습 결과물을 빠르게 눈으로 확인할 수 있습니다. 또한 에디터는 코드 규모가 커지더라도 기능을 모듈화하기 좋아 객체지향/아키텍처 연습에 제격입니다.
개발 환경 준비
필수 소프트웨어
- Python 3.10 이상 (tkinter 포함 배포 권장)
- 코드 편집기 (VS Code, PyCharm, 등)
- (선택) PyQt6: pip install pyqt6
폴더 구조
editor-tutorial/ ├─ tk_editor.py ├─ find_replace_dialog.py └─ pyqt_editor.py (선택)
모듈을 분리하면 유지보수가 쉬워집니다.
파트 A — tkinter 미니 텍스트 에디터
표준 라이브러리만으로 동작하는 기본 버전입니다. 다음 코드는 파일 열기/저장, 되돌리기/다시하기, 줄·열 상태 표시, 단축키를 포함합니다. 먼저 tk_editor.py 파일을 만들고 아래 코드를 붙여 넣으세요.
# tk_editor.py import tkinter as tk from tkinter import filedialog, messagebox from tkinter import ttk
APP_TITLE = "Mini Editor (tkinter)"
DEFAULT_FONT = ("Consolas", 12)
class MiniEditor(tk.Tk):
def init(self):
super().init()
self.title(APP_TITLE)
self.geometry("900x600")
self._create_widgets()
self._create_menu()
self._bind_shortcuts()
self.filepath = None
self._update_title()
# --- UI 구성 ---
def _create_widgets(self):
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.text = tk.Text(self, wrap="none", undo=True, font=DEFAULT_FONT)
self.text.grid(row=0, column=0, sticky="nsew")
# 스크롤바
yscroll = ttk.Scrollbar(self, orient="vertical", command=self.text.yview)
xscroll = ttk.Scrollbar(self, orient="horizontal", command=self.text.xview)
self.text.configure(yscrollcommand=yscroll.set, xscrollcommand=xscroll.set)
yscroll.grid(row=0, column=1, sticky="ns")
xscroll.grid(row=1, column=0, sticky="ew")
# 상태바
self.status = ttk.Label(self, text="Ln 1, Col 1", anchor="w")
self.status.grid(row=2, column=0, columnspan=2, sticky="ew")
self.text.bind("<KeyRelease>", self._update_status)
self.text.bind("<ButtonRelease-1>", self._update_status)
def _create_menu(self):
menubar = tk.Menu(self)
filemenu = tk.Menu(menubar, tearoff=0)
filemenu.add_command(label="New", command=self.new_file, accelerator="Ctrl+N")
filemenu.add_command(label="Open...", command=self.open_file, accelerator="Ctrl+O")
filemenu.add_command(label="Save", command=self.save_file, accelerator="Ctrl+S")
filemenu.add_command(label="Save As...", command=self.save_file_as, accelerator="Ctrl+Shift+S")
filemenu.add_separator()
filemenu.add_command(label="Exit", command=self.quit, accelerator="Ctrl+Q")
menubar.add_cascade(label="File", menu=filemenu)
editmenu = tk.Menu(menubar, tearoff=0)
editmenu.add_command(label="Undo", command=lambda: self.text.event_generate("<Control-z>"), accelerator="Ctrl+Z")
editmenu.add_command(label="Redo", command=lambda: self.text.event_generate("<Control-y>"), accelerator="Ctrl+Y")
editmenu.add_separator()
editmenu.add_command(label="Find/Replace", command=self.find_replace, accelerator="Ctrl+F")
menubar.add_cascade(label="Edit", menu=editmenu)
viewmenu = tk.Menu(menubar, tearoff=0)
viewmenu.add_checkbutton(label="Wrap Lines", command=self._toggle_wrap)
menubar.add_cascade(label="View", menu=viewmenu)
self.config(menu=menubar)
def _bind_shortcuts(self):
self.bind("<Control-n>", lambda e: self.new_file())
self.bind("<Control-o>", lambda e: self.open_file())
self.bind("<Control-s>", lambda e: self.save_file())
self.bind("<Control-S>", lambda e: self.save_file_as())
self.bind("<Control-q>", lambda e: self.quit())
self.bind("<Control-f>", lambda e: self.find_replace())
# --- 파일 동작 ---
def new_file(self):
if self._confirm_discard():
self.text.delete("1.0", "end")
self.filepath = None
self._update_title()
def open_file(self):
if not self._confirm_discard():
return
path = filedialog.askopenfilename(filetypes=[("Text", "*.txt"), ("All Files", "*.*")])
if not path:
return
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
self.text.delete("1.0", "end")
self.text.insert("1.0", content)
self.filepath = path
self._update_title()
def save_file(self):
if self.filepath is None:
return self.save_file_as()
return self._write_to_path(self.filepath)
def save_file_as(self):
path = filedialog.asksaveasfilename(defaultextension=".txt",
filetypes=[("Text", "*.txt"), ("All Files", "*.*")])
if not path:
return
self._write_to_path(path)
self.filepath = path
self._update_title()
def _write_to_path(self, path):
try:
with open(path, "w", encoding="utf-8") as f:
f.write(self.text.get("1.0", "end-1c"))
self.status.config(text=f"Saved to: {path}")
except Exception as e:
messagebox.showerror("Save Error", str(e))
# --- 편의 기능 ---
def _toggle_wrap(self):
wrap = self.text.cget("wrap")
self.text.configure(wrap="char" if wrap == "none" else "none")
def _update_status(self, event=None):
idx = self.text.index("insert") # e.g. "3.14"
line, col = idx.split(".")
self.status.config(text=f"Ln {line}, Col {int(col)+1}")
def _confirm_discard(self):
# 변경 감지 간단 버전: 내용 있으면 확인
has_text = bool(self.text.get("1.0", "end-1c").strip())
if has_text:
return messagebox.askyesno("Discard changes?", "현재 내용을 버리고 진행할까요?")
return True
def _update_title(self):
name = self.filepath if self.filepath else "Untitled"
self.title(f"{APP_TITLE} - {name}")
def find_replace(self):
FindReplaceDialog(self, self.text)
--- 간단한 찾기/바꾸기 다이얼로그 ---
class FindReplaceDialog(tk.Toplevel):
def init(self, master, text_widget: tk.Text):
super().init(master)
self.title("Find / Replace")
self.resizable(False, False)
self.text_widget = text_widget
self._build()
def _build(self):
frm = ttk.Frame(self, padding=10)
frm.grid(row=0, column=0)
ttk.Label(frm, text="Find").grid(row=0, column=0, sticky="w")
self.find_entry = ttk.Entry(frm, width=30)
self.find_entry.grid(row=0, column=1, padx=6, pady=3)
ttk.Label(frm, text="Replace").grid(row=1, column=0, sticky="w")
self.replace_entry = ttk.Entry(frm, width=30)
self.replace_entry.grid(row=1, column=1, padx=6, pady=3)
btns = ttk.Frame(frm)
btns.grid(row=0, column=2, rowspan=2, padx=6)
ttk.Button(btns, text="Find Next", command=self.find_next).grid(row=0, column=0, sticky="ew", pady=2)
ttk.Button(btns, text="Replace", command=self.replace_one).grid(row=1, column=0, sticky="ew", pady=2)
ttk.Button(btns, text="Replace All", command=self.replace_all).grid(row=2, column=0, sticky="ew", pady=2)
self.bind("<Return>", lambda e: self.find_next())
self.find_entry.focus_set()
def find_next(self):
target = self.find_entry.get()
if not target:
return
start = self.text_widget.index("insert")
pos = self.text_widget.search(target, start, stopindex="end")
if not pos:
messagebox.showinfo("Find", "더 이상 찾을 결과가 없습니다.")
return
end = f"{pos}+{len(target)}c"
self.text_widget.tag_remove("sel", "1.0", "end")
self.text_widget.tag_add("sel", pos, end)
self.text_widget.mark_set("insert", end)
self.text_widget.see(pos)
def replace_one(self):
if self.text_widget.tag_ranges("sel"):
self.text_widget.delete("sel.first", "sel.last")
self.text_widget.insert("insert", self.replace_entry.get())
else:
self.find_next()
def replace_all(self):
target = self.find_entry.get()
repl = self.replace_entry.get()
if not target:
return
start = "1.0"
count = 0
while True:
pos = self.text_widget.search(target, start, stopindex="end")
if not pos:
break
end = f"{pos}+{len(target)}c"
self.text_widget.delete(pos, end)
self.text_widget.insert(pos, repl)
start = f"{pos}+{len(repl)}c"
count += 1
messagebox.showinfo("Replace All", f"{count}개 바꾸기 완료")
if name == "main":
app = MiniEditor()
app.mainloop()
핵심 기능 추가 포인트
1) 자동 줄 번호 표시 (캔버스 이용)
줄 번호는 별도의 좌측 캔버스를 두고 스크롤 이벤트에 동기화하거나, Text 위젯을 비편집용으로 하나 더 둬서 흉내낼 수 있습니다. 아래 코드는 간단한 캔버스 기반 줄 번호 표시 예시입니다. 성능상 많은 문서를 다뤄야 한다면 가시 영역만 그리도록 최적화하세요.
# 줄번호 예시 스니펫 (핵심만) self.linenum = tk.Canvas(self, width=40, background="#f6f6f6", highlightthickness=0) self.linenum.grid(row=0, column=0, sticky="ns") self.text.grid(row=0, column=1, sticky="nsew")
def redraw_linenumbers(event=None):
self.linenum.delete("all")
i = self.text.index("@0,0") # 가시 영역 시작
while True:
dline = self.text.dlineinfo(i)
if dline is None: break
y = dline[1]
linenum = str(i).split(".")[0]
self.linenum.create_text(35, y, anchor="ne", text=linenum)
i = self.text.index(f"{i}+1line")
self.text.bind("", redraw_linenumbers)
self.text.bind("", redraw_linenumbers)
2) 최근 문서 자동 복구
앱 종료 전 마지막 내용을 ~/.mini_editor/recover.txt 같은 경로에 임시 저장해 두면 비정상 종료 후에도 복구할 수 있습니다. 쓰기 주기를 너무 짧게 하면 성능 저하가 있으니 디바운스 타이머를 두는 것이 실용적입니다.
# 자동 백업(오토세이브) 스니펫 import os, pathlib from threading import Timer
backup_path = pathlib.Path.home() / ".mini_editor" / "recover.txt"
backup_path.parent.mkdir(parents=True, exist_ok=True)
backup_timer = None
def schedule_backup():
global backup_timer
if backup_timer:
backup_timer.cancel()
backup_timer = Timer(1.0, do_backup) # 1초 디바운스
backup_timer.start()
def do_backup():
with open(backup_path, "w", encoding="utf-8") as f:
f.write(self.text.get("1.0", "end-1c"))
self.text.bind("", lambda e: schedule_backup())
3) 인코딩 선택 및 줄바꿈(EOL) 표시
다양한 파일 인코딩을 지원하려면 저장/열기 대화상자 옆에 콤보박스로 인코딩을 고르도록 추가하세요. 또한 화면 하단 상태바에 CRLF / LF 표시를 넣으면 협업 시 유용합니다.
코드 구조와 리팩터링 포인트
- 명령(Command)와 UI 분리: 메뉴/단축키가 호출하는 로직을 메서드로 분리하고, 테스트 가능한 순수 함수로 감싸면 유지보수성이 올라갑니다.
- 상태 관리: 현재 파일 경로, 수정 여부(dirty), 뷰 설정(wrap/linenumbers) 등을 단일 상태로 관리하면 기능 추가가 쉬워집니다.
- 플러그인 구조: 찾기/바꾸기, 하이라이트, 자동완성 등을 독립 모듈로 두고 에디터에서 훅을 제공하면 확장성이 좋아집니다.
- 키 바인딩 테이블: OS별 단축키 차이(Ctrl vs Cmd)를 감싸는 맵을 두면 이식성이 좋아집니다.
파트 B — PyQt6로 확장해 보기 (선택)
더 세련된 위젯과 플랫폼 일관성을 원한다면 PyQt6를 시도할 수 있습니다. 아래 예제는 QPlainTextEdit 기반의 간단 에디터로, 메뉴/단축키/상태바를 포함합니다. 설치는 pip install pyqt6 한 줄이면 됩니다.
# pyqt_editor.py import sys from PyQt6.QtWidgets import ( QApplication, QMainWindow, QPlainTextEdit, QFileDialog, QMessageBox, QStatusBar, QToolBar, QAction ) from PyQt6.QtGui import QKeySequence from PyQt6.QtCore import QFile, QTextStream
class PyQtEditor(QMainWindow):
def init(self):
super().init()
self.setWindowTitle("Mini Editor (PyQt6)")
self.resize(900, 600)
self.editor = QPlainTextEdit()
self.setCentralWidget(self.editor)
self.filepath = None
self._create_actions()
self._create_toolbar()
self._create_statusbar()
self._bind_events()
def _create_actions(self):
self.act_new = QAction("New", self, shortcut=QKeySequence("Ctrl+N"), triggered=self.new_file)
self.act_open = QAction("Open", self, shortcut=QKeySequence("Ctrl+O"), triggered=self.open_file)
self.act_save = QAction("Save", self, shortcut=QKeySequence("Ctrl+S"), triggered=self.save_file)
self.act_saveas = QAction("Save As", self, shortcut=QKeySequence("Ctrl+Shift+S"), triggered=self.save_file_as)
self.act_find = QAction("Find", self, shortcut=QKeySequence("Ctrl+F"), triggered=self.find_text)
def _create_toolbar(self):
tb = QToolBar("Main")
tb.addAction(self.act_new)
tb.addAction(self.act_open)
tb.addAction(self.act_save)
tb.addAction(self.act_saveas)
tb.addAction(self.act_find)
self.addToolBar(tb)
def _create_statusbar(self):
self.status = QStatusBar()
self.setStatusBar(self.status)
self.status.showMessage("Ready")
def _bind_events(self):
self.editor.cursorPositionChanged.connect(self._update_status)
def new_file(self):
if not self._confirm_discard(): return
self.editor.setPlainText("")
self.filepath = None
self._update_title()
def open_file(self):
if not self._confirm_discard(): return
path, _ = QFileDialog.getOpenFileName(self, "Open", filter="Text Files (*.txt);;All Files (*)")
if not path: return
with open(path, "r", encoding="utf-8", errors="replace") as f:
self.editor.setPlainText(f.read())
self.filepath = path
self._update_title()
def save_file(self):
if self.filepath is None:
return self.save_file_as()
with open(self.filepath, "w", encoding="utf-8") as f:
f.write(self.editor.toPlainText())
self.status.showMessage(f"Saved: {self.filepath}", 3000)
def save_file_as(self):
path, _ = QFileDialog.getSaveFileName(self, "Save As", filter="Text Files (*.txt);;All Files (*)")
if not path: return
self.filepath = path
self.save_file()
def _confirm_discard(self):
if self.editor.toPlainText().strip():
ret = QMessageBox.question(self, "Discard changes?", "현재 내용을 버리고 진행할까요?")
return ret == QMessageBox.StandardButton.Yes
return True
def _update_status(self):
cur = self.editor.textCursor()
line = cur.blockNumber() + 1
col = cur.positionInBlock() + 1
self.status.showMessage(f"Ln {line}, Col {col}")
def _update_title(self):
name = self.filepath if self.filepath else "Untitled"
self.setWindowTitle(f"Mini Editor (PyQt6) - {name}")
def find_text(self):
# 간단 구현: 기본 find 다이얼로그 없이 현재 텍스트에서 다음 매치로 이동
target, ok = QFileDialog.getOpenFileName # 자리표시자: 실제론 QInputDialog.getText 사용 권장
QMessageBox.information(self, "Tip", "실전에서는 QInputDialog.getText로 찾을 문자열을 입력받아 구현하세요.")
if name == "main":
app = QApplication(sys.argv)
win = PyQtEditor()
win.show()
sys.exit(app.exec())
위 PyQt 예제의 find_text는 설명을 위한 자리표시자입니다. 실전에서는 QInputDialog.getText를 사용해 문자열을 입력받고, QTextDocument::find 또는 커서 이동을 조합해 탐색/하이라이팅을 구현하세요.
응용 과제 & 확장 아이디어
- Syntax Highlight: 키워드 정규식과 Text.tag_config(tkinter) 또는 QSyntaxHighlighter(PyQt)로 간단한 파이썬 하이라이터 만들기.
- 탭(Tab) 인터페이스: 여러 문서를 동시에 열 수 있도록 노트북 위젯(ttk.Notebook) 또는 QTabWidget 적용.
- 최근 파일 목록: 마지막에 편집한 파일 5개를 기록하고, 시작 시 퀵오픈 제공.
- 자동 저장/백업: 디바운스로 1~3초마다 스냅샷 저장 및 복구 UI 제공.
- 마크다운 미리보기: 오른쪽 패널에 렌더링(웹뷰) 추가. 수업에서는 아키텍처 토론용으로도 좋습니다.
정리 및 마무리
우리는 파이썬의 tkinter로 손쉬운 미니 텍스트 에디터를 구현하고, PyQt6로 확장 가능성을 살펴보았습니다. 핵심은 파일 I/O, 편집 명령, 상태 표시, 검색처럼 GUI 앱의 보편적 패턴을 직접 만들어 보는 것입니다. 실습을 거듭하며 모듈 분리와 상태 관리를 다듬으면, 코드 에디터나 노트 앱 같은 더 큰 프로젝트로 자연스럽게 나아갈 수 있습니다.
수업/블로그 게시 시에는 본문 코드를 깃 저장소로 묶고, requirements.txt 및 실행 스크립트를 함께 제공하면 독자가 더욱 쉽게 따라올 수 있습니다. 다음 단계로는 하이라이팅, 탭, 마크다운 미리보기 중 하나를 골라 직접 구현해 보세요. 작게 시작해 점진적으로 확장하는 것이 가장 좋은 학습 경로입니다.
Comments
Post a Comment