程式碼檢視器 – A-Notepad-v2.py

← 返回清單
# notepad.py
# Python 3.x + Tkinter 記事本(暗黑/淺色主題切換、字體可調、Ctrl +/-/0)
import os
import tkinter as tk
from tkinter import filedialog, messagebox
import tkinter.font as tkfont

APP_NAME = "記事本"

DARK_THEME = {
    "bg": "#1e1e1e",
    "fg": "#d4d4d4",
    "insertbg": "#ffffff",
    "selectbg": "#264f78",
    "selectfg": "#ffffff",
}
LIGHT_THEME = {
    "bg": "#ffffff",
    "fg": "#000000",
    "insertbg": "#000000",
    "selectbg": "#cce8ff",
    "selectfg": "#000000",
}

def _default_zh_font():
    if os.name == "nt":
        # Windows:優先使用微軟正黑體 UI,無則退回微軟正黑體
        return "Microsoft JhengHei UI"
    # macOS 常見中文字體,可自行改為喜歡的中文字型
    return "PingFang TC"

# 預設字型與大小(依需求)
DEFAULT_FONT_FAMILY = _default_zh_font()
DEFAULT_FONT_FALLBACK = "Microsoft JhengHei"  # Windows 的備選
DEFAULT_FONT_SIZE = 15

class SimpleNotepad:
    def __init__(self, root):
        self.root = root
        self.current_file = None
        self.dirty = False

        # 視窗設定
        self.root.title(f"未命名 - {APP_NAME}")
        self.root.geometry("900x650")

        # 主題(預設暗黑)
        self.theme_is_dark = True
        self.theme_var = tk.StringVar(value="dark")  # radiobutton 用

        # 字型(如主要字型不存在則自動退回)
        try:
            self.text_font = tkfont.Font(family=DEFAULT_FONT_FAMILY, size=DEFAULT_FONT_SIZE)
        except tk.TclError:
            self.text_font = tkfont.Font(
                family=(DEFAULT_FONT_FALLBACK if os.name == "nt" else "Menlo"),
                size=DEFAULT_FONT_SIZE
            )

        # 編輯區 + 捲軸
        self.text = tk.Text(
            root, undo=True, wrap="word", font=self.text_font, tabs=("2c"),
            padx=5, pady=5,  # 內距:讓文字與邊框約 5px 間距
            highlightthickness=0, relief="flat", bd=0
        )
        self.text.pack(fill="both", expand=True, side="left")
        self.yscroll = tk.Scrollbar(root, command=self.text.yview)
        self.yscroll.pack(fill="y", side="right")
        self.text.configure(yscrollcommand=self.yscroll.set)

        # 套用主題
        self.apply_theme()

        # 監聽變動
        self.text.bind("<<Modified>>", self._on_text_modified)

        # 功能表
        menubar = tk.Menu(root)

        # 檔案
        filemenu = tk.Menu(menubar, tearoff=0)
        filemenu.add_command(label="新增 (Ctrl+N)", command=self.new_file)
        filemenu.add_command(label="開啟… (Ctrl+O)", command=self.open_file)
        filemenu.add_separator()
        filemenu.add_command(label="儲存 (Ctrl+S)", command=self.save_file)
        filemenu.add_command(label="另存新檔… (Ctrl+Shift+S)", command=self.save_as)
        filemenu.add_separator()
        filemenu.add_command(label="離開 (Ctrl+Q)", command=self.on_exit)
        menubar.add_cascade(label="檔案", menu=filemenu)

        # 編輯
        editmenu = tk.Menu(menubar, tearoff=0)
        editmenu.add_command(label="復原 (Ctrl+Z)", command=lambda: self.text.event_generate("<<Undo>>"))
        editmenu.add_command(label="取消復原 (Ctrl+Y)", command=lambda: self.text.event_generate("<<Redo>>"))
        editmenu.add_separator()
        editmenu.add_command(label="剪下 (Ctrl+X)", command=lambda: self.text.event_generate("<<Cut>>"))
        editmenu.add_command(label="複製 (Ctrl+C)", command=lambda: self.text.event_generate("<<Copy>>"))
        editmenu.add_command(label="貼上 (Ctrl+V)", command=lambda: self.text.event_generate("<<Paste>>"))
        editmenu.add_separator()
        editmenu.add_command(label="全選 (Ctrl+A)", command=self.select_all)
        menubar.add_cascade(label="編輯", menu=editmenu)

        # 檢視
        viewmenu = tk.Menu(menubar, tearoff=0)
        self.word_wrap = tk.BooleanVar(value=True)
        viewmenu.add_checkbutton(label="自動換行", onvalue=True, offvalue=False,
                                 variable=self.word_wrap, command=self.toggle_wrap)

        # 主題(顯示:深色模式 / 淺色模式)
        theme_menu = tk.Menu(viewmenu, tearoff=0)
        theme_menu.add_radiobutton(
            label="深色模式", value="dark", variable=self.theme_var,
            command=lambda: self.set_theme(True)
        )
        theme_menu.add_radiobutton(
            label="淺色模式", value="light", variable=self.theme_var,
            command=lambda: self.set_theme(False)
        )
        viewmenu.add_cascade(label="主題", menu=theme_menu)

        # 字體大小
        font_menu = tk.Menu(viewmenu, tearoff=0)
        font_menu.add_command(label="放大 (Ctrl +)", command=self.increase_font)
        font_menu.add_command(label="縮小 (Ctrl -)", command=self.decrease_font)
        font_menu.add_command(label="重設 (Ctrl 0)", command=self.reset_font)
        viewmenu.add_cascade(label="字體大小", menu=font_menu)

        menubar.add_cascade(label="檢視", menu=viewmenu)

        # 說明
        helpmenu = tk.Menu(menubar, tearoff=0)
        helpmenu.add_command(label="關於", command=lambda: messagebox.showinfo("關於", "軟體開發 Kelvin Huang"))
        menubar.add_cascade(label="說明", menu=helpmenu)

        root.config(menu=menubar)

        # 快捷鍵
        root.bind("<Control-n>", lambda e: self.new_file())
        root.bind("<Control-o>", lambda e: self.open_file())
        root.bind("<Control-s>", lambda e: self.save_file())
        root.bind("<Control-S>", lambda e: self.save_as())  # Shift+Ctrl+S
        root.bind("<Control-q>", lambda e: self.on_exit())
        root.bind("<Control-a>", lambda e: self.select_all())

        # 字體大小快捷鍵
        root.bind("<Control-plus>", self._inc_font_event)
        root.bind("<Control-KP_Add>", self._inc_font_event)
        root.bind("<Control-=>", self._inc_font_event)   # Ctrl + '=' 也視為放大
        root.bind("<Control-minus>", self._dec_font_event)
        root.bind("<Control-KP_Subtract>", self._dec_font_event)
        root.bind("<Control-0>", self._reset_font_event)

        # 關閉視窗攔截
        root.protocol("WM_DELETE_WINDOW", self.on_exit)

    # ===== 主題 =====
    def apply_theme(self):
        theme = DARK_THEME if self.theme_is_dark else LIGHT_THEME
        self.root.configure(bg=theme["bg"])
        self.text.configure(
            bg=theme["bg"],
            fg=theme["fg"],
            insertbackground=theme["insertbg"],
            selectbackground=theme["selectbg"],
            selectforeground=theme["selectfg"],
        )

    def set_theme(self, dark: bool):
        self.theme_is_dark = dark
        self.theme_var.set("dark" if dark else "light")
        self.apply_theme()

    # ===== 標題/狀態 =====
    def update_title(self):
        name = os.path.basename(self.current_file) if self.current_file else "未命名"
        prefix = "* " if self.dirty else ""
        self.root.title(f"{prefix}{name} - {APP_NAME}")

    def _on_text_modified(self, _event=None):
        # Tk 的 edit_modified flag 在觸發後需要手動清除
        self.text.edit_modified(False)
        self.dirty = True
        self.update_title()

    # ===== 變更確認 =====
    def _maybe_save_changes(self) -> bool:
        if self._text_changed_since_save():
            ans = messagebox.askyesnocancel("提示", "檔案尚未儲存,是否要儲存?")
            if ans is None:   # 取消
                return False
            if ans:           # 是 → 儲存
                return self.save_file()
        return True

    def _text_changed_since_save(self) -> bool:
        if not self.dirty:
            return False
        current = self.text.get("1.0", "end-1c")
        if self.current_file and os.path.exists(self.current_file):
            try:
                with open(self.current_file, "r", encoding="utf-8", errors="replace") as f:
                    return current != f.read()
            except Exception:
                return True
        else:
            return current != ""

    # ===== 檔案 =====
    def new_file(self):
        if not self._maybe_save_changes():
            return
        self.text.delete("1.0", "end")
        self.current_file = None
        self.dirty = False
        self.update_title()

    def open_file(self):
        if not self._maybe_save_changes():
            return
        path = filedialog.askopenfilename(
            title="開啟檔案",
            filetypes=[("文字檔案", "*.txt"), ("所有檔案", "*.*")]
        )
        if not path:
            return
        try:
            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.current_file = path
            self.dirty = False
            self.text.edit_modified(False)
            self.update_title()
        except Exception as e:
            messagebox.showerror("錯誤", f"無法開啟檔案:\n{e}")

    def save_file(self):
        if not self.current_file:
            return self.save_as()
        return self._write_to_path(self.current_file)

    def save_as(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".txt",
            filetypes=[("文字檔案", "*.txt"), ("所有檔案", "*.*")],
            title="另存新檔"
        )
        if not path:
            return False
        self.current_file = path
        return self._write_to_path(path)

    def _write_to_path(self, path) -> bool:
        try:
            text = self.text.get("1.0", "end-1c")
            with open(path, "w", encoding="utf-8") as f:
                f.write(text)
            self.dirty = False
            self.text.edit_modified(False)
            self.update_title()
            return True
        except Exception as e:
            messagebox.showerror("錯誤", f"無法儲存檔案:\n{e}")
            return False

    # ===== 字體大小 =====
    def increase_font(self, step: int = 2):
        size = self.text_font.actual("size")
        self.text_font.configure(size=min(size + step, 96))

    def decrease_font(self, step: int = 2):
        size = self.text_font.actual("size")
        self.text_font.configure(size=max(size - step, 6))

    def reset_font(self):
        self.text_font.configure(size=DEFAULT_FONT_SIZE)

    # 快捷鍵事件包裝
    def _inc_font_event(self, event=None):
        self.increase_font()
        return "break"

    def _dec_font_event(self, event=None):
        self.decrease_font()
        return "break"

    def _reset_font_event(self, event=None):
        self.reset_font()
        return "break"

    # 其他
    def select_all(self):
        self.text.tag_add("sel", "1.0", "end-1c")
        return "break"

    def toggle_wrap(self):
        self.text.configure(wrap="word" if self.word_wrap.get() else "none")

    def on_exit(self):
        if not self._maybe_save_changes():
            return
        self.root.destroy()


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