on
news
- Get link
- X
- Other Apps
파이썬으로 텍스트 에디터를 만드는 것은 GUI 프로그래밍의 기초를 배우기에 아주 좋은 주제입니다. 이 글은 수업·동아리·개인 프로젝트에서 바로 활용할 수 있는 완결형 실습 포스팅으로, tkinter 기반의 미니 에디터부터 PyQt6 버전까지 단계별 예제를 제공합니다. 파일 열기/저장, 되돌리기, 찾기/바꾸기, 줄/열 표시와 같은 필수 기능을 짚으며 코드 설계 관점도 함께 정리합니다.
본 포스팅은 순수 파이썬 표준 라이브러리(tkinter)로 시작해, 선택적으로 PyQt6 확장 예제를 제공합니다. 설치와 실행은 Windows, macOS, Linux에서 모두 가능합니다.
에디터는 사용자 입력, 파일 I/O, 단축키, 메뉴, 상태 표시 등 GUI 앱의 핵심 요소를 모두 담고 있습니다. 파이썬의 간결한 문법과 풍부한 GUI 프레임워크 덕분에 학습 장벽이 낮고, 실습 결과물을 빠르게 눈으로 확인할 수 있습니다. 또한 에디터는 코드 규모가 커지더라도 기능을 모듈화하기 좋아 객체지향/아키텍처 연습에 제격입니다.
editor-tutorial/ ├─ tk_editor.py ├─ find_replace_dialog.py └─ pyqt_editor.py (선택) 모듈을 분리하면 유지보수가 쉬워집니다.
표준 라이브러리만으로 동작하는 기본 버전입니다. 다음 코드는 파일 열기/저장, 되돌리기/다시하기, 줄·열 상태 표시, 단축키를 포함합니다. 먼저 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()
줄 번호는 별도의 좌측 캔버스를 두고 스크롤 이벤트에 동기화하거나, 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)
앱 종료 전 마지막 내용을 ~/.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())
다양한 파일 인코딩을 지원하려면 저장/열기 대화상자 옆에 콤보박스로 인코딩을 고르도록 추가하세요. 또한 화면 하단 상태바에 CRLF / LF 표시를 넣으면 협업 시 유용합니다.
더 세련된 위젯과 플랫폼 일관성을 원한다면 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 또는 커서 이동을 조합해 탐색/하이라이팅을 구현하세요.
우리는 파이썬의 tkinter로 손쉬운 미니 텍스트 에디터를 구현하고, PyQt6로 확장 가능성을 살펴보았습니다. 핵심은 파일 I/O, 편집 명령, 상태 표시, 검색처럼 GUI 앱의 보편적 패턴을 직접 만들어 보는 것입니다. 실습을 거듭하며 모듈 분리와 상태 관리를 다듬으면, 코드 에디터나 노트 앱 같은 더 큰 프로젝트로 자연스럽게 나아갈 수 있습니다.
수업/블로그 게시 시에는 본문 코드를 깃 저장소로 묶고, requirements.txt 및 실행 스크립트를 함께 제공하면 독자가 더욱 쉽게 따라올 수 있습니다. 다음 단계로는 하이라이팅, 탭, 마크다운 미리보기 중 하나를 골라 직접 구현해 보세요. 작게 시작해 점진적으로 확장하는 것이 가장 좋은 학습 경로입니다.
Comments
Post a Comment