python tkinter.Text 高级用法 -- 设计功能齐全的文本编辑器

众所周知, tkinterText文本框功能强大, Python自带的IDLE编辑器也是用tkinter编写的。这里作者也用tkinterText文本框等控件, 设计功能较齐全的文本编辑器程序。

目标功能:

  • 编辑文本文件
  • 编辑二进制文件 (字符会以转义序列形式显示, 如’abc\xff’)
  • 支持ansi、gbk、utf-8等编码, 支持自动检测文件编码
  • 支持查找、替换
  • 支持撤销、重做
  • 支持自由选择主题、改变字体
  • 编辑python代码文件时, 支持代码高亮显示, 类似IDLE。

    目录

    • 效果图
    • 1.创建tkinter界面、菜单
    • 2.文本打开, 保存
    • 3.文本编辑
    • 4.编辑二进制文件
    • 5.查找、替换对话框
    • 6.代码高亮显示
    • 7.选择文本框主题颜色
    • 8.完成
    • 9.附: 配置 & 文件拖放功能

效果图

python tkinter.Text 高级用法 -- 设计功能齐全的文本编辑器_第1张图片
python tkinter.Text 高级用法 -- 设计功能齐全的文本编辑器_第2张图片
源代码见: pynotepad.py · GitCode 。代码较多, 在下文中将会逐渐讲解。

1.创建tkinter界面、菜单

在tkinter中, 应用大多使用类实现, 因此设计的Editor类是内置Tk类的继承。
以下是涉及到的tkinter基础知识:

一、Tk()对象

  • pack()方法
    参数side:指定将控件停靠在哪一条边上,LEFT、RIGHT、TOP或BOTTOM。
    参数expand, fill:让控件随父控件(这里是窗口)一起增大,可将expand设为True, fill设为BOTH。
  • protocol()方法
    Tk对象.protocol("协议名称",函数名)
    该方法用于设置窗口管理程序应用程序之间的协议, 最常用的是WM_DELETE_WINDOW协议, 用于窗口关闭按钮点击时执行什么函数。
    需要注意的是: WM_DELETE_WINDOW中函数调用完后, Tk窗口不会自动关闭, 需要在函数中手动调用Tk对象.destroy()关闭。

二、ScrolledText控件
ScrolledText控件继承了普通Text控件的所有功能, 而且增加了滚动条。要编辑ScrolledText控件的内容,可使用insert, delete方法。

  • ScrolledText控件的字体:
    字体属性默认为"<字体名> <大小> <样式>"的格式。需要注意的是: tkinter会将带空格的字体名称用{}括起来。
    要获取所有可用字体名称, 可用tkinter.font.families()函数。
    要设置或获取字体, 可使用text["font"]键。

三、ComboBox (组合框)控件

  • value属性
    该属性获取或设置组合框列表中所有的项, 可以是列表或元组。
  • textvariable属性
    该属性为一个StringVar()对象, 获取或设置组合框中的文字。
  • <>事件
    该事件用于bind()方法, 当组合框列表中的某一项被选中时触发。

四、Menu控件
方法add_command: 增加一个菜单项。
方法add_checkbutton: 增加一个单选的菜单项。
方法entryconfig: 配置菜单的某一项, 可用该方法设置菜单是否有效。

五、tkinter.messagebox
该模块中有askyesno等方法, 注意调用时, 需指定parent为一个Tk窗口实例。
(其它的Frame等控件, 就不一一介绍了。)

注意事项:
在tkinter中, 一切对象(Tk()除外)都有自己的masterparent, 表示对象属于哪个父对象(父窗口), 如: 代码btn=Button(root)中, root的作用就是master
由于程序支持多窗口, 在创建IntVar, StringVar, 和显示消息框, 调用(选择文件)对话框的时候, 一定要设置masterparent参数或属性, 否则会引起混乱。

import sys,os,pickle
from tkinter import *
from tkinter.scrolledtext import ScrolledText
import tkinter.ttk as ttk
import tkinter.messagebox as msgbox
import tkinter.filedialog as filediag
import tkinter.simpledialog as simpledialog
from tkinter.colorchooser import askcolor
from tkinter import font

# 以下为可选(非必需)的模块
import webbrowser
try:import windnd
except ImportError:windnd=None
try:import chardet
except ImportError:chardet=None

def handle(err,parent=None):
    # 用于处理错误
    # showinfo()中,parent参数指定消息框的父窗口
    msgbox.showinfo("错误",type(err).__name__+': '+str(err),parent=parent)
class Editor(Tk): # 继承Tk类
    TITLE="PyNotepad"
    encodings="ansi","utf-8","utf-16","utf-32","gbk","big5"
    # 判断是否有chardet库, 有就启用"自动"功能
    if chardet is not None:encodings=("自动",)+encodings
    ICON="notepad.ico" # 以下为常量, 用大写字母表示
    NORMAL_CODING="自动" if chardet is not None else "utf-8"
    FONTSIZES=8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 36, 48
    NORMAL_FONT='宋体'
    NORMAL_FONTSIZE=11
    TEXT_BG="SystemWindow";TEXT_FG="SystemWindowText" # 系统默认颜色
    FILETYPES=[("所有文件","*.*")]
    CONFIGFILE=os.getenv("userprofile")+"\\.pynotepad.pkl"
    AUTOWRAP=CHAR
    SHOW_STATUS=True

    instances=[]
    def __init__(self,filename=""):
        super().__init__()
        self.withdraw() # 暂时隐藏窗口,避免调用create_widgets()时窗口闪烁
        self.title(self.TITLE) # 初始化时预先显示标题
        self.bind("",self.window_onkey)
        self.protocol("WM_DELETE_WINDOW",self.ask_for_save) # 窗口关闭按钮点击时, 自动调用ask_for_save()方法

        self.isbinary=self.file_modified=False
        self.colorobj=self._codefilter=None
        self._dialogs={}
        Editor.instances.append(self)

        self.load_icon()
        self.loadconfig() # 加载配置, 需要"附: 配置 & 文件拖放功能"中的代码
        self.create_widgets()
        self.wm_deiconify();self.update() # wm_deiconfy恢复被隐藏的窗口
        if windnd:windnd.hook_dropfiles(self,func=self.onfiledrag);self.drag_files=[]
        self.filename=''
        if filename:
            self.load(filename)
        else:self.change_title() # 更改标题
    def load_icon(self): # 自动寻找图标
        for path in sys.path + [os.path.split(sys.executable)[0]]:# 后半部分用于Py2exe
            try:
                self.iconbitmap("{}\{}".format(path,self.ICON))
            except TclError:pass
            else:break
    def create_widgets(self):
        # 创建控件
        self.statusbar=Frame(self)
        if self.SHOW_STATUS:
            self.statusbar.pack(side=BOTTOM,fill=X)
        self.status=Label(self.statusbar,justify=RIGHT)
        self.status.pack(side=RIGHT)
        self.txt_decoded=ScrolledText(self.statusbar,
                        bg=self.TEXT_BG,fg=self.TEXT_FG,width=6,height=6)
        self.txt_decoded.insert('1.0',"在这里查看和编辑解码的数据")
        self.hexdata=ScrolledText(self.statusbar,
                        bg=self.TEXT_BG,fg=self.TEXT_FG,width=14,height=5)
        self.hexdata.insert('1.0',"在这里查看hex十六进制值")

        frame=Frame(self)
        frame.pack(side=TOP,fill=X)

        ttk.Button(frame,text='新建', command=self.new,width=7).pack(side=LEFT)
        ttk.Button(frame,text='打开', command=self.open,width=7).pack(side=LEFT)
        ttk.Button(frame,text='打开二进制文件',
                   command=self.open_as_binary,width=13).pack(side=LEFT)
        ttk.Button(frame,text='保存', command=self.save,width=7).pack(side=LEFT)

        Label(frame,text="编码:").pack(side=LEFT)
        self.coding=StringVar(self)
        self.coding.set(self.NORMAL_CODING)
        coding=ttk.Combobox(frame,textvariable=self.coding)
        def tip(event):
            self.msg['text']='重新打开或保存即可生效'
            self.msg.after(2500,clear)
        def clear():self.msg['text']=''
        coding.bind('<>',tip)
        coding["value"]=self.encodings
        coding.pack(side=LEFT)
        self.msg=Label(frame)
        self.msg.pack(side=LEFT)

        self.contents=ScrolledText(self,undo=True, width=75, height=24,
                        font = (self.NORMAL_FONT,self.NORMAL_FONTSIZE,"normal"),
                        wrap=self.AUTOWRAP, bg=self.TEXT_BG,fg=self.TEXT_FG)
        self.contents.pack(expand=True,fill=BOTH)
        self.contents.bind("",self.text_change)
        self.contents.bind("",self.update_status)
        order = self.contents.bindtags() # 修复无法获取选定的文本的bug
        self.contents.bindtags((order[1], order[0])+order[2:])
        self.update_offset()

        self.create_menu()
    def create_menu(self):
        menu=Menu(self)
        filemenu=Menu(self,tearoff=False)
        filemenu.add_command(label="新建",
                             command=self.new,accelerator="Ctrl+N")
        filemenu.add_command(label="新建二进制文件",command=self.new_binary)
        filemenu.add_command(label="打开",
                             command=self.open,accelerator="Ctrl+O")
        filemenu.add_command(label="打开二进制文件",command=self.open_as_binary)
        filemenu.add_command(label="保存",
                             command=self.save,accelerator="Ctrl+S")
        filemenu.add_command(label="另存为",command=self.save_as)
        filemenu.add_separator()
        filemenu.add_command(label="退出",command=self.ask_for_save)

        self.editmenu=Menu(self.contents,tearoff=False)
        master = self.contents
        self.editmenu.add_command(label="剪切  ",
                         command=lambda:self.text_change()\
                                ==master.event_generate("<>"))
        self.editmenu.add_command(label="复制  ",
                         command=lambda:master.event_generate("<>"))
        self.editmenu.add_command(label="粘贴  ",
                         command=lambda:self.text_change()\
                                ==master.event_generate("<>"))
        self.editmenu.add_separator()
        self.editmenu.add_command(label="查找",accelerator="Ctrl+F",
                                  command=lambda:self.show_dialog(SearchDialog))
        self.editmenu.add_command(label="查找下一个",accelerator="F3",
                                  command=self.findnext)
        self.editmenu.add_command(label="替换",accelerator="Ctrl+H",
                                  command=lambda:self.show_dialog(ReplaceDialog))
        self.editmenu.add_separator()
        self.editmenu.add_command(label="插入十六进制数据",state=DISABLED,
                                  command=self.insert_hex)

        view=Menu(self.contents,tearoff=False)
        self.is_autowrap=IntVar(self.contents) # 是否自动换行
        self.is_autowrap.set(1 if self.AUTOWRAP!=NONE else 0)
        view.add_checkbutton(label="自动换行", command=self.set_wrap,
                             variable=self.is_autowrap)
        fontsize=Menu(self.contents,tearoff=False)
        fontsize.add_command(label="选择字体",
                             command=self.choose_font)
        fontsize.add_separator()
        fontsize.add_command(label="增大字体   ",accelerator='Ctrl+ "+"',
                             command=self.increase_font)
        fontsize.add_command(label="减小字体   ",accelerator='Ctrl+ "-"',
                             command=self.decrease_font)
        fontsize.add_separator()

        for i in range(len(self.FONTSIZES)):
            def resize(index=i):
                self.set_fontsize(index)
            fontsize.add_command(label=self.FONTSIZES[i],command=resize)

        self.contents.bind("",
                    lambda event:self.editmenu.post(event.x_root,event.y_root))
        view.add_cascade(label="字体",menu=fontsize)
        theme_menu=Menu(self,tearoff=False)
        theme_menu.add_command(label="选择前景色",command=self.select_fg)
        theme_menu.add_command(label="选择背景色",command=self.select_bg)
        theme_menu.add_command(label="重置",command=self.reset_theme)
        view.add_cascade(label="主题",menu=theme_menu)
        self._show_status=IntVar(self)
        self._show_status.set(1 if self.SHOW_STATUS else 0)
        view.add_checkbutton(label="显示状态栏",command=self.show_statusbar,
                         variable=self._show_status)

        helpmenu=Menu(self,tearoff=False)
        helpmenu.add_command(label="关于",command=self.about)
        helpmenu.add_command(label="反馈",command=self.feedback)

        menu.add_cascade(label="文件",menu=filemenu)
        menu.add_cascade(label="编辑",menu=self.editmenu)
        menu.add_cascade(label="查看",menu=view)
        menu.add_cascade(label="帮助",menu=helpmenu)

        # 创建弹出在self.txt_decoded和self.hexdata的菜单
        popup1=Menu(self.txt_decoded,tearoff=False)
        def _cut():
            self.txt_decoded.event_generate("<>")
            self._edit_decoded_event()
        def _paste():
            self.txt_decoded.event_generate("<>")
            self._edit_decoded_event()
        popup1.add_command(label="剪切",command=_cut)
        popup1.add_command(
            label="复制",command=lambda:self.txt_decoded.event_generate("<>"))
        popup1.add_command(label="粘贴",command=_paste)

        popup2=Menu(self.hexdata,tearoff=False)
        popup2.add_command(
            label="复制",command=lambda:self.hexdata.event_generate("<>"))

        self.txt_decoded.bind("",
                    lambda event:popup1.post(event.x_root,event.y_root))
        self.txt_decoded.bind("",self._edit_decoded_event)
        self.hexdata.bind("",
                    lambda event:popup2.post(event.x_root,event.y_root))

        # 显示菜单
        self.config(menu=menu)
    def create_binarytools(self): # 用于二进制文件
        if self.isbinary: # self.txt_decoded 用于显示解码的转义字符
            self.txt_decoded.pack(side=LEFT,expand=True,fill=BOTH)
            self.hexdata.pack(fill=Y) # hexdata 用于显示转义字符的十六进制
            self.status.pack_forget()
            self.status.pack(fill=X)
            self.editmenu.entryconfig(8,state=NORMAL)
        else: # 隐藏工具
            if self.txt_decoded:
                self.txt_decoded.pack_forget()
            if self.hexdata:
                self.hexdata.pack_forget()
            self.status.pack(side=RIGHT)
            self.editmenu.entryconfig(8,state=DISABLED) # 禁止插入

以下是部分控件事件的处理, 包含了实现快捷键、改变字体和显示/隐藏状态栏功能:

    def _get_fontname(self):
        font=' '.join(self.contents["font"].split(' ')[:-2])
        # tkinter会将带空格的字体名称用{}括起来
        if '{' in font:
            font = font[1:-1]
        return font
    def set_fontsize(self,index):
        newsize=self.FONTSIZES[index]
        fontname = self._get_fontname()
        self.contents["font"]=(fontname,newsize,"normal")
    def choose_font(self):
        def ok():
            self.contents["font"]=[opt.get()] + \
                       self.contents["font"].split(' ')[-2:] # 保留原先大小、样式
            dialog.destroy()
        dialog = Toplevel(self)
        dialog.title('选择字体')
        dialog.resizable(False,False)
        dialog.attributes('-toolwindow',True)
        opt = ttk.Combobox(dialog)
        # tkinter.font.families() 获取所有字体名称, 注意root参数
        opt['values']=sorted(font.families(root=self))
        opt.grid(row=0,column=0,columnspan=2,padx=15,pady=20)
        ttk.Button(dialog,text='确定',command=ok).grid(row=1,column=0)
        ttk.Button(dialog,text='取消',command=dialog.destroy).grid(row=1,column=1)
        oldfont = self._get_fontname()
        opt.set(oldfont)
        dialog.grab_set() # 对话框打开时, 不允许用户操作主窗口
        dialog.focus_force()
    def increase_font(self):
        # 增大字体
        fontsize=int(self.contents["font"].split(' ')[1])
        index=self.FONTSIZES.index(fontsize)+1
        if 0<=index<len(self.FONTSIZES): self.set_fontsize(index)
    def decrease_font(self):
        # 减小字体
        fontsize=int(self.contents["font"].split(' ')[1])
        index=self.FONTSIZES.index(fontsize)-1
        if 0<=index<len(self.FONTSIZES): self.set_fontsize(index)
    def set_wrap(self):
        if self.is_autowrap.get():
            self.contents['wrap'] = CHAR
        else:
            self.contents['wrap'] = NONE
        # 注意:由于tkinter会自动设置菜单复选框的变量, 所以不需要此行代码
##        self.is_autowrap.set(int(not self.is_autowrap.get()))
    def show_statusbar(self):
        if self._show_status.get():
            if self.isbinary:
                self.statusbar.pack(side=BOTTOM,fill=X)
            else:
                self.statusbar.pack(side=BOTTOM,fill=X)
        else:
            self.statusbar.pack_forget()
    def window_onkey(self,event):
        # 实现快捷键的部分
        # 如果按下Ctrl键
        if event.state in (4,6,12,14,36,38,44,46): # 适应多种按键情况(Num,Caps,Scroll)
            key=event.keysym.lower()
            if key=='o':#按下Ctrl+O键
                self.open()
            elif key=='s':#Ctrl+S键
                self.save()
            elif key=='n':
                self.new()
            elif key=='f':
                self.show_dialog(SearchDialog)
            elif key=='h':
                self.show_dialog(ReplaceDialog)
            elif key=='equal':#Ctrl+ "+" 增大字体
                self.increase_font()
            elif key=='minus':#Ctrl+ "-" 减小字体
                self.decrease_font()
        elif event.keysym.lower()=='f3':
            self.findnext()
        elif event.keycode == 93: # 按下了菜单键
            self.editmenu.post(self.winfo_x()+self.winfo_width(),
                               self.winfo_y()+self.winfo_height())
    def about(self):
        msgbox.showinfo("关于","版本: %s\n作者: %s"%(__version__, __author__), parent=self)

2.文本打开, 保存

打开文件有两种方法, 一种是在当前窗口中打开, 第二种是新建一个Editor实例, 在新窗口中打开。这里在新窗口中打开文件。

ask_for_save()的部分有一些复杂, 需要多次判断, 如判断用户是否取消操作、询问打开文件前是否需要保存等。
如: 用户在"是否保存"中选择了"是", 但在输入文件名的对话框中点击了取消, 应该如何处理?具体可看注释。

关于使用chardet库自动检测编码:
chardet.detect()函数返回一个字典, 包含检测结果。其中encoding键即为检测到的编码。

在之前的Editor类中加入以下代码:

    def new(self): # 新建一个Editor实例
        try:self.saveconfig() # 保存配置,使新的窗口加载修改后的配置
        except OSError:pass # 忽略写入文件可能产生的异常
        window=Editor()
        window.focus_force()
        return window
    def new_binary(self): # 创建新二进制文件
        try:self.saveconfig()
        except OSError:pass
        window=Editor()
        window.isbinary=True
        window.create_binarytools()
        window.change_title()
        window.change_mode()
        window.contents.edit_reset()
        window.focus_force()
        return window
    def open(self):
        #加载一个文件
        filename=filediag.askopenfilename(master=self,title='打开',
                            initialdir=os.path.split(self.filename)[0],
                            filetypes=self.FILETYPES)
        if not filename:return
        if not self.filename and not self.file_modified: # 如果是刚新建的, 在当前窗口中打开
            self.load(filename)
        else:self.new().load(filename)
    def open_as_binary(self):
        filename=filediag.askopenfilename(master=self,title='打开二进制文件',
                                initialdir=os.path.split(self.filename)[0],
                                filetypes=self.FILETYPES)
        if not filename:return
        if not self.filename and not self.file_modified: # 如果是刚新建的
            self.load(filename,binary=True)
        else:self.new().load(filename,binary=True)
    def load(self,filename,binary=False):
        # 加载文件
        self.isbinary=binary
        try:
            data=self._load_data(filename)
            if data==0:return
            self.filename=filename
            self.contents.delete('1.0', END)
            if self.isbinary:
                self.contents.insert(INSERT,data)
            else:
                for char in data:
                    try:
                        self.contents.insert(INSERT,char)
                    except TclError:self.contents.insert(INSERT,' ')
            self.contents.mark_set(INSERT,"1.0")
            self.create_binarytools()
            self.file_modified=False
            self.change_title()
            self.change_mode()
            self.contents.edit_reset() # 重置文本框的撤销功能
            self.contents.focus_force()
        except Exception as err:handle(err,parent=self)
    def _load_data(self,filename):
        # 从文件加载数据
        f=open(filename,"rb")
        if self.isbinary:
            data=to_escape_str(f.read())
            return data
        else:
            try:
                #读取文件,并对文件内容进行编码
                raw=f.read()
                if self.coding.get()=="自动":
                    # 调用chardet库
                    encoding=chardet.detect(raw[:100000])['encoding']
                    if encoding is None:
                        encoding='utf-8'
                    self.coding.set(encoding)
                data=str(raw,encoding=self.coding.get())
            except UnicodeDecodeError:
                f.seek(0)
                result=msgbox.askyesnocancel("PyNotepad","""%s编码无法解码此文件,
是否使用二进制模式打开?"""%self.coding.get(),parent=self)
                if result:
                    self.isbinary=True
                    data=to_escape_str(f.read())
                elif result is not None:
                    self.isbinary=False
                    data=str(f.read(),encoding=self.coding.get(),errors="replace")
                else:
                    return 0 # 表示取消
            return data
    def ask_for_save(self,quit=True):
        my_ret=None
        if self.file_modified:
            retval=msgbox.askyesnocancel("文件尚未保存",
                              "是否保存{}的更改?".format(
                                  os.path.split(self.filename)[1] or "当前文件")
                              ,parent=self)
            # retval 为 None表示取消, False为否, True为是
            if not retval is None: 
                if retval==True:
                    # 是
                    ret=self.save()
                    # 在保存对话框中取消
                    if ret==0:
                        my_ret=0;quit=False
                # 否
            else:
                # 取消
                my_ret=0;quit=False  # 0表示cancel
        if quit:
            Editor.windows.remove(self)
            try:self.saveconfig() # 保存配置, 见附
            except OSError:pass
            self.destroy() # tkinter不会自动关闭窗口, 需调用函数手动关闭
        return my_ret
    def save(self):
        #保存文件
        if not self.filename:
            self.filename=filediag.asksaveasfilename(master=self,
                    initialdir=os.path.split(self.filename)[0],
                    filetypes=self.FILETYPES)
        filename=self.filename
        if filename.strip():
            text=self.contents.get('1.0', END)[:-1] # [:-1]: 去除末尾换行符
            if self.isbinary:
                data=to_bytes(text)
            else:
                data=bytes(text,encoding=self.coding.get(),errors='replace')
                # Text文本框的bug:避免多余的\r换行符
                # 如:输入文字foobar, data中变成\rfoobar
                # -感谢文章末尾用户评论的反馈-
                data=data.replace(b'\r',b'')
            with open(filename, 'wb') as f:
                f.write(data)
            self.filename=filename
            self.file_modified=False
            self.change_title()
            self.change_mode()
        else:
            return 0 # 0表示cancel
    def save_as(self):
        filename=filediag.asksaveasfilename(master=self,
                    initialdir=os.path.split(self.filename)[0],
                    filetypes=self.FILETYPES)
        if filename: # 如果未选择取消
            self.filename=filename
            self.save()
    def change_title(self):
        file = os.path.split(self.filename)[1] or "未命名"
        newtitle="PyNotepad - "+ file +\
                  (" (二进制模式)" if self.isbinary else '')
        if self.file_modified:
            newtitle="*%s*"%newtitle
        self.title(newtitle)

3.文本编辑

其中, text_change()在文本被修改时调用, update_status()update_offset()用于更新状态栏中的数据。
在二进制模式中, update_status获取用户选择的文本, 更新解码的数据和十六进制值。update_offset更新偏移量。在文本模式中, update_offset更新当前的行数和列数。
在ScrolledText控件中,
获取选择的文本: text.get(SEL_FIRST,SEL_LAST)
获取当前光标位置: text.index(INSERT)
在之前的Editor类中加入以下代码:

    def text_change(self,event=None):
        self.file_modified=True
        self.update_status();self.change_title()
    def update_status(self,event=None):
        if not self._show_status.get():return
        if self.isbinary:
            # 用于二进制文件
            try:
                selected=self.contents.get(SEL_FIRST,SEL_LAST) # 获取从开头到光标处的文本
                raw=to_bytes(selected)
                coding=self.coding.get()
                # 调用chardet库
                if coding=="自动":
                    coding=chardet.detect(raw[:100000])['encoding']
                    if coding is None:coding='utf-8'
                try:text=str(raw,encoding=coding,
                             errors="backslashreplace")
                except TypeError:
                    # 修复Python 3.4中的bug: don't know how to handle
                    # UnicodeDecodeError in error callback
                    text=str(raw,encoding=coding,
                             errors="replace")
                except LookupError as err: # 未知编码
                    handle(err,parent=self);return
                self.txt_decoded.delete("1.0",END)
                self.txt_decoded.insert(INSERT,text)
                self.hexdata.delete("1.0",END)
                self.hexdata.insert(INSERT,view_hex(raw))
                self.status["text"]="选区长度: %d (Bytes)"%len(raw)
            except (TclError,SyntaxError): #忽略未选取内容, 或格式不正确
                self.txt_decoded.delete("1.0",END)
                self.hexdata.delete("1.0",END)
                self.update_offset()
        else:self.update_offset()
    def update_offset(self,event=None):
        if self.isbinary:
            prev=self.contents.get("1.0",INSERT) # 获取从开头到光标处的文本
            try:
                data=to_bytes(prev)
            except SyntaxError:
                sep='\\'
                prev=sep.join(prev.split(sep)[0:-1])
                try:data=to_bytes(prev)
                except SyntaxError:data=None
            if data is not None:
                self.status["text"]="偏移量: {} ({})"\
                                     .format(len(data),hex(len(data)))
        else:
            offset=self.contents.index(INSERT).split('.') # 不能用CURRENT
            self.status["text"]="Ln: {}  Col: {}".format(*offset)

4.编辑二进制文件

在Python中, bytes数据可用转义序列形式表示, 如\x00\x01\x02\n属于转义序列,
可通过repr(bytes)[2:-1],eval('b"""'+str+'"""') 实现转义序列与bytes类型的转换。
这次, 在Editor类外部加入以下代码:

def to_escape_str(byte):
    # 将字节(bytes)转换为转义字符串
    str='';length=1024
    for i in range(0,len(byte),length):
        str+=repr( byte[i: i+length] ) [2:-1]
        str+='\n'
    return str

def to_bytes(escape_str):
    # 将转义字符串转换为字节
    # -*****- 1.2.5版更新: 忽略二进制模式中文字的换行符
    escape_str=escape_str.replace('\n','')
    escape_str=escape_str.replace('"""','\\"\\"\\"') # 避免引号导致的SyntaxError
    escape_str=escape_str.replace("'''","\\'\\'\\'")
    try:
        return eval('b"""'+escape_str+'"""')
    except SyntaxError:
        return eval("b'''"+escape_str+"'''")

以下代码用于兼容WinHex等软件的十六进制数据, 使用bytes对象的fromhexhex方法。
知识点: bytes对象有fromhex()hex()方法, 可实现十六进制数据和bytes对象之间的相互转换fromhex()方法会忽略参数中的空格, 换行符等字符。
在Editor类外部加入以下代码:

def view_hex(byte):
    result=''
    for i in range(0,len(byte)):
        result+= byte[i:i+1].hex().zfill(2) + ' '
        if (i+1) % 4 == 0:result+='\n'
    return result

Editor类中加入以下代码:

    def insert_hex(self):
        hex = simpledialog.askstring('',
                    "输入WinHex十六进制数据(如:00 1a 3d ff) :",parent=self)
        if hex is None:return
        try:
            data=bytes.fromhex(hex)
            self.contents.insert('insert',to_escape_str(data))
        except Exception as err:
            handle(err,parent=self)
    # 以下代码用于直接在self.txt_decoded中编辑数据
    def _edit_decoded_event(self,event=None):
        self.after(20,self.edit_decoded) # 如果不使用after(),self.txt_decoded.get不会返回最新的值
    def edit_decoded(self):
        range_=self.contents.tag_ranges(SEL) # 获取选区
        if range_:
            start,end=range_[0].string,range_[1].string # 转换为字符串
        else:start=self.contents.index(INSERT);end=None
        try:
            coding=self.coding.get()
            if coding=="自动":
                msgbox.showinfo('','不支持自动编码, 请选择或输入其他编码',parent=self)
                return
            byte = self.txt_decoded.get('1.0',END)[:-1].encode(coding)
            esc_char = to_escape_str(byte,linesep=False)
            self.file_modified=True;self.change_title()
            if range_:
                self.contents.delete(start,end)
            self.contents.insert(start,esc_char)
            end = '%s+%dc'%(start, len(esc_char))
            self.contents.tag_add(SEL,start,end)
        except Exception as err:handle(err,parent=self)

5.查找、替换对话框

这里主要用到文本框Text的search方法,
该函数接收2个必选参数, 分别是pattern和index, index为起始索引, search 方法返回起始索引处或之后的第一个匹配项的索引。
search方法还有一些可选参数:
regexp: 是否使用正则表达式查找 (比自己编写代码查找要快)。
nocase: 是否不区分大小写。

显示对话框时, 还需调用Tk,Toplevel的focus方法, 用于使对象获得焦点。
注意: 创建IntVar(), StringVar()时, 需指定参数master, 避免创建的变量无法使用。
* 使对话框跟随父窗口最小化、恢复显示的方法: 调用transient()方法。

Editor类外部加入以下代码:

class SearchDialog(Toplevel):
    #查找对话框
    def __init__(self,master):
        self.master=master
        self.coding=self.master.coding.get()
    def init_window(self,title="查找"):
        Toplevel.__init__(self,self.master)
        self.title(title)
        self.attributes("-toolwindow",True)
        self.attributes("-topmost",True)
        # 当父窗口隐藏后,窗口也跟随父窗口隐藏
        self.transient(self.master)
        self.wm_protocol("WM_DELETE_WINDOW",self.onquit)
    def show(self):
        self.init_window()
        frame=Frame(self)
        ttk.Button(frame,text="查找下一个",command=self.search).pack()
        ttk.Button(frame,text="退出",command=self.onquit).pack()
        frame.pack(side=RIGHT,fill=Y)
        inputbox=Frame(self)
        Label(inputbox,text="查找内容:").pack(side=LEFT)
        self.keyword=StringVar(self.master)
        keyword=ttk.Entry(inputbox,textvariable=self.keyword)
        keyword.pack(side=LEFT,expand=True,fill=X)
        keyword.bind("",self.search)
        keyword.focus_force()
        inputbox.pack(fill=X)
        options=Frame(self)
        self.create_options(options)
        options.pack(fill=X)
    def create_options(self,master):
        Label(master,text="选项: ").pack(side=LEFT)
        self.use_regexpr=IntVar(self.master)
        ttk.Checkbutton(master,text="使用正则表达式",variable=self.use_regexpr)\
        .pack(side=LEFT)
        self.match_case=IntVar(self.master)
        ttk.Checkbutton(master,text="区分大小写",variable=self.match_case)\
        .pack(side=LEFT)
        self.use_escape_char=IntVar(self.master)
        self.use_escape_char.set(self.master.isbinary)
        ttk.Checkbutton(master,text="使用转义字符",variable=self.use_escape_char)\
        .pack(side=LEFT)

    def search(self,event=None,mark=True,bell=True):
        text=self.master.contents
        key=self.keyword.get()
        if not key:return
        # 验证用户输入是否正常
        if self.use_escape_char.get():
            try:key=str(to_bytes(key),encoding=self.coding)
            except Exception as err:
                handle(err,parent=self);return
        if self.use_regexpr.get():
            try:re.compile(key)
            except re.error as err:
                handle(err,parent=self);return
        # 默认从当前光标位置开始查找
        pos=text.search(key,INSERT,'end-1c',# end-1c:忽略末尾换行符
                        regexp=self.use_regexpr.get(),
                        nocase=not self.match_case.get())
        if not pos:
            # 尝试从开头循环查找
            pos=text.search(key,'1.0','end-1c',
                        regexp=self.use_regexpr.get(),
                        nocase=not self.match_case.get())
        if pos:
            if self.use_regexpr.get(): # 获取正则表达式匹配的字符串长度
                text_after = text.get(pos,END)
                flag = re.IGNORECASE if not self.match_case.get() else 0
                length = re.match(key,text_after,flag).span()[1]
            else:
                length = len(key)
            newpos="%s+%dc"%(pos,length)
            text.mark_set(INSERT,newpos)
            if mark:self.mark_text(pos,newpos)
            return pos,newpos
        elif bell: # 未找到,返回None
            bell_(widget=self)
    def findnext(self,cursor_pos='end',mark=True,bell=True):
        # cursor_pos:标记文本后将光标放在找到文本开头还是末尾
        # 因为search()默认从当前光标位置开始查找
        # end 用于查找下一个操作, start 用于替换操作
        result=self.search(mark=mark,bell=bell)
        if not result:return
        if cursor_pos=='end':
            self.master.contents.mark_set('insert',result[1])
        elif cursor_pos=='start':
            self.master.contents.mark_set('insert',result[0])
        return result
    def mark_text(self,start_pos,end_pos):
        text=self.master.contents
        text.tag_remove("sel","1.0",END) # 移除旧的tag
        # 已知问题: 代码高亮显示时, 无法突出显示找到的文字
        text.tag_add("sel", start_pos,end_pos) # 添加新的tag 
        lines=text.get('1.0',END)[:-1].count(os.linesep) + 1
        lineno=int(start_pos.split('.')[0])
         # 滚动文本框, 使被找到的内容显示 ( 由于只判断行数, 已知有bug); 另外, text['height']不会随文本框缩放而变化
        text.yview('moveto', str((lineno-text['height'])/lines))
        text.focus_force()
        self.master.update_status()
    def onquit(self):
        self.withdraw()

class ReplaceDialog(SearchDialog):
    #替换对话框
    def show(self):
        self.init_window(title="替换")
        frame=Frame(self)
        ttk.Button(frame,text="查找下一个", command=self._findnext).pack()
        ttk.Button(frame,text="替换", command=self.replace).pack()
        ttk.Button(frame,text="全部替换", command=self.replace_all).pack()
        ttk.Button(frame,text="退出", command=self.onquit).pack()
        frame.pack(side=RIGHT,fill=Y)

        inputbox=Frame(self)
        Label(inputbox,text="查找内容:").pack(side=LEFT)
        self.keyword=StringVar(self.master)
        keyword=ttk.Entry(inputbox,textvariable=self.keyword)
        keyword.pack(side=LEFT,expand=True,fill=X)
        keyword.focus_force()
        inputbox.pack(fill=X)

        replace=Frame(self)
        Label(replace,text="替换为:  ").pack(side=LEFT)
        self.text_to_replace=StringVar(self.master)
        replace_text=ttk.Entry(replace,textvariable=self.text_to_replace)
        replace_text.pack(side=LEFT,expand=True,fill=X)
        replace_text.bind("",self.replace)
        replace.pack(fill=X)

        options=Frame(self)
        self.create_options(options)
        options.pack(fill=X)

    def _findnext(self):# 仅用于"查找下一个"按钮功能
        text=self.master.contents
        sel_range=text.tag_ranges('sel') # 获得选区的起点和终点
        if sel_range:
            selectarea = sel_range[0].string, sel_range[1].string
            result = self.findnext('start')
            if result is None:return
            if result[0] == selectarea[0]: # 若仍停留在原位置
                text.mark_set('insert',result[1])# 从选区终点继续查找
                self.findnext('start')
        else:
            self.findnext('start')
    def replace(self,bell=True,mark=True):
        text=self.master.contents
        result=self.search(mark=False,bell=bell)
        if not result:return # 标志已无文本可替换
        self.master.text_change()
        pos,newpos=result
        newtext=self.text_to_replace.get()
        try:
            if self.use_escape_char.get():
                newtext=to_bytes(newtext).decode(self.master.coding.get())
            if self.use_regexpr.get():
                old=text.get(pos,newpos)
                newtext=re.sub(self.keyword.get(),newtext,old)
        except Exception as err:
            handle(err,parent=self);return
        text.delete(pos,newpos)
        text.insert(pos,newtext)
        end_pos="%s+%dc"%(pos,len(newtext))
        if mark:self.mark_text(pos,end_pos)
        return pos,end_pos
    def replace_all(self):
        self.master.contents.mark_set("insert","1.0") # 将光标移到开头
        flag=False # 标志是否已有文字被替换

        # 以下代码会导致无限替换, 使程序卡死, 新的代码修复了该bug
        #while self.replace(bell=False)!=-1:
        #    flag=True
        last = (0,0)
        while True:
            result=self.replace(bell=False,mark=False)
            if result is None:break
            flag = True
            result = self.findnext('start',bell=False,mark=False)
            if result is None:return
            ln,col = result[0].split('.')
            ln = int(ln);col = int(col)
            # 判断新的偏移量是增加还是减小
            if ln < last[0] or (ln==last[0] and col<last[1]):
                self.mark_text(*result) # 已完成一轮替换
                break
            last=ln,col
        if not flag:bell_()

Editor类内部加入以下代码:

    def findnext(self):
        fd = self._dialogs.get(SearchDialog,None)
        if fd:
            if fd.findnext():return
        rd = self._dialogs.get(ReplaceDialog,None)
        if rd:
            rd.findnext()
    def show_dialog(self,dialog_type):
        # dialog_type是对话框的类型
        if dialog_type in self._dialogs:
            # 不再显示新的对话框
            d=self._dialogs[dialog_type]
            d.state('normal') # 恢复隐藏的窗口
            d.focus_force()
        else:
            d = dialog_type(self);d.show()
            self._dialogs[dialog_type] = d

6.代码高亮显示

有点复杂, 该部分参考了turtledemo模块的源码。程序调用了IDLE内置的代码高亮显示组件。
在文件开头加入:

try:
    from idlelib.colorizer import ColorDelegator
    from idlelib.percolator import Percolator
except ImportError: # 可能未安装IDLE
    ColorDelegator=Percolator=None

Editor类中加入以下代码:

    def change_mode(self):
        if ColorDelegator:
            if self.filename.lower().endswith((".py",".pyw"))\
                   and (not self.isbinary):
                   # 设置代码高亮显示
                self._codefilter=ColorDelegator()
                if not self.colorobj:
                    self.colorobj=Percolator(self.contents)
                self.colorobj.insertfilter(self._codefilter)
                self.set_tag_bg()
            elif self.colorobj and self._codefilter.delegate:
                # 取消代码高亮显示
                self.colorobj.removefilter(self._codefilter)

7.选择文本框主题颜色

设置文本框颜色, 主要用到文本框的"bg"(背景色),"fg"(前景色)属性键。
选择颜色, 使用tkinter.colorchooser中的askcolor函数, 其中color参数指定默认的颜色。
Editor类中加入如下代码:

    def select_fg(self):
        self.contents["fg"]=self.txt_decoded["fg"]\
                    =self.hexdata["fg"] = askcolor(parent=self,
                                                   color=self.contents["fg"])[1]
    def select_bg(self):
        self.contents["bg"]=self.txt_decoded["bg"]\
                    =self.hexdata["bg"] = askcolor(parent=self,
                                                   color=self.contents["bg"])[1]
        self.set_tag_bg()
    def reset_theme(self):
        self.contents["bg"]=self.txt_decoded["bg"]\
                    =self.hexdata["bg"] = "SystemWindow"
        self.contents["fg"]=self.txt_decoded["fg"]\
                    =self.hexdata["fg"] = "SystemWindowText"
        self.set_tag_bg()

我们知道, 在代码高亮显示中, ColorDelegator会创建tag, 且tag默认是白色的。因此需要设置tag的背景色, 使其与文本框背景色匹配。

    def set_tag_bg(self):
        for tag in self.contents.tag_names(): # tag_names()获取所有的tag名称
            if tag.lower() != "sel":
                self.contents.tag_config(tag, background=self.contents["bg"])

8.完成

在文件末尾加入:

def main(): 
    # 初始化Editor实例
    if len(sys.argv)>1:# 检测程序启动参数 sys.argv
        for arg in sys.argv[1:]:
            try:
                Editor(arg)
            except OSError:pass
    else: Editor()
    mainloop()

__author__="qfcy"
__version__="1.3.4"
if __name__=="__main__":main()

到这里, 你已经基本完成了文本编辑器的制作。
如果你不想复制代码,请看这里: ​pynotepad.py · qfcy_ / Python · GitCode

9.附: 配置 & 文件拖放功能

配置功能使用pickle模块, 保存和读取数据。

class Editor(Tk):
   # --snip-- (略)
   CONFIGFILE=os.getenv("userprofile")+"\.pynotepad.pkl"
   # --snip--
    def loadconfig(self):
        try:
            with open(self.CONFIGFILE,'rb') as f:
                cfg=pickle.load(f)
                for key in cfg:
                    setattr(Editor,key,cfg[key]) # 设置Editor类的各个属性
        except OSError:
            pass
    # bug修复:未安装chardet时编码设为"自动"的情况
        if Editor.NORMAL_CODING=="自动" and not chardet:
            Editor.NORMAL_CODING="utf-8"
    def saveconfig(self):
        font=self.contents['font'].split(' ')
        cfg={'NORMAL_CODING':self.coding.get(),
             'NORMAL_FONT': self._get_fontname(),
             'NORMAL_FONTSIZE': int(font[-2]),
             'AUTOWRAP': self.contents['wrap'],
             'TEXT_BG':self.contents["bg"],
             'TEXT_FG':self.contents["fg"],
             "SHOW_STATUS":bool(self._show_status.get())}
        with open(self.CONFIGFILE,'wb') as f:
            pickle.dump(cfg,f)

文件拖放功能使用了windnd模块, 具体可参考: tk windnd 实现文件拖放到窗口
Editor类中加入:

    def __init__(self, filename=""):
        # --snip--
        if windnd:windnd.hook_dropfiles(self,func=self.onfiledrag)
        self.drag_files=[]
    def onfiledrag(self,files):
        self.drag_files=files
        self.after(50,self.onfiledrag2)
    def onfiledrag2(self):
        self.saveconfig()
        if not self.filename and not self.file_modified: # 如果刚新建窗口
            # 注意windnd的文件名为二进制, 需要被解码
            self.load(self.drag_files[0].decode('ansi'))
            del self.drag_files[0]
        for item in self.drag_files:
            Editor(item.decode('ansi'))

你可能感兴趣的:(Python,tkinter,python,tkinter,编辑器)