python textEditor 自制编辑器

2022-05-13 10:38:05 浏览数 (1)

image.png

guimaker.py

""" ############################################################################### An extended Frame that makes window menus and toolbars automatically. Use GuiMakerFrameMenu for embedded components (makes frame-based menus). Use GuiMakerWindowMenu for top-level windows (makes Tk8.0 window menus). See the self-test code (and PyEdit) for an example layout tree format. ############################################################################### """

import sys from tkinter import * # widget classes from tkinter.messagebox import showinfo

class GuiMaker(Frame): menuBar = [] # class defaults toolBar = [] # change per instance in subclasses helpButton = True # set these in start() if need self

代码语言:javascript复制
def __init__(self, parent=None):
    Frame.__init__(self, parent)
    self.pack(expand=YES, fill=BOTH)        # make frame stretchable
    self.start()                            # for subclass: set menu/toolBar
    self.makeMenuBar()                      # done here: build menu bar
    self.makeToolBar()                      # done here: build toolbar
    self.makeWidgets()                      # for subclass: add middle part

def makeMenuBar(self):
    """
    make menu bar at the top (Tk8.0 menus below)
    expand=no, fill=x so same width on resize
    """
    menubar = Frame(self, relief=RAISED, bd=2)
    menubar.pack(side=TOP, fill=X)

    for (name, key, items) in self.menuBar:
        mbutton  = Menubutton(menubar, text=name, underline=key)
        mbutton.pack(side=LEFT)
        pulldown = Menu(mbutton)
        self.addMenuItems(pulldown, items)
        mbutton.config(menu=pulldown)

    if self.helpButton:
        Button(menubar, text    = 'Help',
                        cursor  = 'gumby',
                        relief  = FLAT,
                        command = self.help).pack(side=RIGHT)

def addMenuItems(self, menu, items):
    for item in items:                     # scan nested items list
        if item == 'separator':            # string: add separator
            menu.add_separator({})
        elif type(item) == list:           # list: disabled item list
            for num in item:
                menu.entryconfig(num, state=DISABLED)
        elif type(item[2]) != list:
            menu.add_command(label     = item[0],         # command:
                             underline = item[1],         # add command
                             command   = item[2])         # cmd=callable
        else:
            pullover = Menu(menu)
            self.addMenuItems(pullover, item[2])          # sublist:
            menu.add_cascade(label     = item[0],         # make submenu
                             underline = item[1],         # add cascade
                             menu      = pullover)

def makeToolBar(self):
    """
    make button bar at bottom, if any
    expand=no, fill=x so same width on resize
    this could support images too: see Chapter 9,
    would need prebuilt gifs or PIL for thumbnails
    """
    if self.toolBar:
        toolbar = Frame(self, cursor='hand2', relief=SUNKEN, bd=2)
        toolbar.pack(side=BOTTOM, fill=X)
        for (name, action, where) in self.toolBar:
            Button(toolbar, text=name, command=action).pack(where)

def makeWidgets(self):
    """
    make 'middle' part last, so menu/toolbar
    is always on top/bottom and clipped last;
    override this default, pack middle any side;
    for grid: grid middle part in a packed frame
    """
    name = Label(self,
                 width=40, height=10,
                 relief=SUNKEN, bg='white',
                 text   = self.__class__.__name__,
                 cursor = 'crosshair')
    name.pack(expand=YES, fill=BOTH, side=TOP)

def help(self):
    "override me in subclass"
    showinfo('Help', 'Sorry, no help for '   self.__class__.__name__)

def start(self):
    "override me in subclass: set menu/toolbar with self"
    pass

###############################################################################

Customize for Tk 8.0 main window menu bar, instead of a frame

###############################################################################

GuiMakerFrameMenu = GuiMaker # use this for embedded component menus

class GuiMakerWindowMenu(GuiMaker): # use this for top-level window menus def makeMenuBar(self): menubar = Menu(self.master) self.master.config(menu=menubar)

代码语言:javascript复制
    for (name, key, items) in self.menuBar:
        pulldown = Menu(menubar)
        self.addMenuItems(pulldown, items)
        menubar.add_cascade(label=name, underline=key, menu=pulldown)

    if self.helpButton:
        if sys.platform[:3] == 'win':
            menubar.add_command(label='Help', command=self.help)
        else:
            pulldown = Menu(menubar)  # Linux needs real pull down
            pulldown.add_command(label='About', command=self.help)
            menubar.add_cascade(label='Help', menu=pulldown)

textConfig.py

""" PyEdit (textEditor.py) user startup configuration module; """

----------------------------------------------------------------------------------

General configurations

comment-out any setting in this section to accept Tk or program defaults;

can also change font/colors from GUI menus, and resize window when open;

imported via search path: can define per client app, skipped if not on the path;

----------------------------------------------------------------------------------

initial font # family, size, style

font = ('courier', 9, 'normal') # e.g., style: 'bold italic'

initial color # default=white, black

bg = 'lightcyan' # colorname or RGB hexstr fg = 'black' # e.g., 'powder blue', '#690f96'

initial size

height = 20 # Tk default: 24 lines width = 80 # Tk default: 80 characters

search case-insensitive

caseinsens = True # default=1/True (on)

----------------------------------------------------------------------------------

2.1: Unicode encoding behavior and names for file opens and saves;

attempts the cases listed below in the order shown, until the first one

that works; set all variables to false/empty/0 to use your platform's default

(which is 'utf-8' on Windows, or 'ascii' or 'latin-1' on others like Unix);

savesUseKnownEncoding: 0=No, 1=Yes for Save only, 2=Yes for Save and SaveAs;

imported from this file always: sys.path if main, else package relative;

----------------------------------------------------------------------------------

代码语言:javascript复制
                   # 1) tries internally known type first (e.g., email charset)

opensAskUser = True # 2) if True, try user input next (prefill with defaults) opensEncoding = '' # 3) if nonempty, try this encoding next: 'latin-1', 'cp500' # 4) tries sys.getdefaultencoding() platform default next # 5) uses binary mode bytes and Tk policy as the last resort

savesUseKnownEncoding = 1 # 1) if > 0, try known encoding from last open or save savesAskUser = True # 2) if True, try user input next (prefill with known?) savesEncoding = '' # 3) if nonempty, try this encoding next: 'utf-8', etc # 4) tries sys.getdefaultencoding() as a last resort

formrows.py

"""" create a label entry row frame, with optional file open browse button; this is a separate module because it can save code in other programs too; caller (or callbacks here): retain returned linked var while row is in use; """

from tkinter import * # widgets and presets from tkinter.filedialog import askopenfilename # file selector dialog

def makeFormRow(parent, label, width=15, browse=True, extend=False): var = StringVar() row = Frame(parent) lab = Label(row, text=label '?', relief=RIDGE, width=width) ent = Entry(row, relief=SUNKEN, textvariable=var) row.pack(fill=X) # uses packed row frames lab.pack(side=LEFT) # and fixed-width labels ent.pack(side=LEFT, expand=YES, fill=X) # or use grid(row, col) if browse: btn = Button(row, text='browse...') btn.pack(side=RIGHT) if not extend: btn.config(command= lambda: var.set(askopenfilename() or var.get()) ) else: btn.config(command= lambda: var.set(var.get() ' ' askopenfilename()) ) return var

textEditor.py

""" ################################################################################ PyEdit 2.1: a Python/tkinter text file editor and component.

Uses the Tk text widget, plus GuiMaker menus and toolbar buttons to implement a full-featured text editor that can be run as a standalone program, and attached as a component to other GUIs. Also used by PyMailGUI and PyView to edit mail text and image file notes, and by PyMailGUI and PyDemos in pop-up mode to display source and text files.

New in version 2.1 (4E) -updated to run under Python 3.X (3.1) -added "grep" search menu option and dialog: threaded external files search -verify app exit on quit if changes in other edit windows in process -supports arbitrary Unicode encodings for files: per textConfig.py settings -update change and font dialog implementations to allow many to be open -runs self.update() before setting text in new editor for loadFirst -various improvements to the Run Code option, per the next section

2.1 Run Code improvements: -use base name after chdir to run code file, not possibly relative path -use launch modes that support arguments for run code file mode on Windows -run code inherits launchmodes backslash conversion (no longer required)

New in version 2.0 (3E) -added simple font components input dialog -use Tk 8.4 undo stack API to add undo/redo text modifications -now verifies on quit, open, new, run, only if text modified and unsaved -searches are case-insensitive now by default -configuration module for initial font/color/size/searchcase

TBD (and suggested exercises): -could also allow search case choice in GUI (not just config file) -could use re patterns for searches and greps (see text chapter) -could experiment with syntax-directed text colorization (see IDLE, others) -could try to verify app exit for quit() in non-managed windows too? -could queue each result as found in grep dialog thread to avoid delay -could use images in toolbar buttons (per examples of this in Chapter 9) -could scan line to map Tk insert position column to account for tabs on Info -could experiment with "grep" tbd Unicode issues (see notes in the code); ################################################################################ """

Version = '2.1' import sys, os # platform, args, run tools from tkinter import * # base widgets, constants from tkinter.filedialog import Open, SaveAs # standard dialogs from tkinter.messagebox import showinfo, showerror, askyesno from tkinter.simpledialog import askstring, askinteger from tkinter.colorchooser import askcolor from guimaker import * # Frame menu/toolbar builders

general configurations

try: import textConfig # startup font and colors configs = textConfig.dict # work if not on the path or bad except: # define in client app directory configs = {}

helptext = """PyEdit version %s April, 2010 (2.0: January, 2006) (1.0: October, 2000)

Programming Python, 4th Edition Mark Lutz, for O'Reilly Media, Inc.

A text editor program and embeddable object component, written in Python/tkinter. Use menu tear-offs and toolbar for quick access to actions, and Alt-key shortcuts for menus.

Additions in version %s:

  • supports Python 3.X
  • new "grep" external files search dialog
  • verifies app quit if other edit windows changed
  • supports arbitrary Unicode encodings for files
  • allows multiple change and font dialogs
  • various improvements to the Run Code option

Prior version additions:

  • font pick dialog
  • unlimited undo/redo
  • quit/open/new/run prompt save only if changed
  • searches are case-insensitive
  • startup configuration module textConfig.py """

START = '1.0' # index of first char: row=1,col=0 SEL_FIRST = SEL '.first' # map sel tag to index SEL_LAST = SEL '.last' # same as 'sel.last'

FontScale = 0 # use bigger font on Linux if sys.platform[:3] != 'win': # and other non-Windows boxes FontScale = 3

################################################################################

Main class: implements editor GUI, actions

requires a flavor of GuiMaker to be mixed in by more specific subclasses;

not a direct subclass of GuiMaker because that class takes multiple forms.

################################################################################

class TextEditor: # mix with menu/toolbar Frame class startfiledir = '.' # for dialogs editwindows = [] # for process-wide quit check

代码语言:javascript复制
# Unicode configurations
# imported in class to allow overrides in subclass or self
if __name__ == '__main__':
    from textConfig import (               # my dir is on the path
        opensAskUser, opensEncoding,
        savesUseKnownEncoding, savesAskUser, savesEncoding)
else:
    from .textConfig import (              # 2.1: always from this package
        opensAskUser, opensEncoding,
        savesUseKnownEncoding, savesAskUser, savesEncoding)

ftypes = [('All files',     '*'),                 # for file open dialog
          ('Text files',   '.txt'),               # customize in subclass
          ('Python files', '.py')]                # or set in each instance

colors = [{'fg':'black',      'bg':'white'},      # color pick list
          {'fg':'yellow',     'bg':'black'},      # first item is default
          {'fg':'white',      'bg':'blue'},       # tailor me as desired
          {'fg':'black',      'bg':'beige'},      # or do PickBg/Fg chooser
          {'fg':'yellow',     'bg':'purple'},
          {'fg':'black',      'bg':'brown'},
          {'fg':'lightgreen', 'bg':'darkgreen'},
          {'fg':'darkblue',   'bg':'orange'},
          {'fg':'orange',     'bg':'darkblue'}]

fonts  = [('courier',    9 FontScale, 'normal'),  # platform-neutral fonts
          ('courier',   12 FontScale, 'normal'),  # (family, size, style)
          ('courier',   10 FontScale, 'bold'),    # or pop up a listbox
          ('courier',   10 FontScale, 'italic'),  # make bigger on Linux
          ('times',     10 FontScale, 'normal'),  # use 'bold italic' for 2
          ('helvetica', 10 FontScale, 'normal'),  # also 'underline', etc.
          ('ariel',     10 FontScale, 'normal'),
          ('system',    10 FontScale, 'normal'),
          ('courier',   20 FontScale, 'normal')]

def __init__(self, loadFirst='', loadEncode=''):
    if not isinstance(self, GuiMaker):
        raise TypeError('TextEditor needs a GuiMaker mixin')
    self.setFileName(None)
    self.lastfind   = None
    self.openDialog = None
    self.saveDialog = None
    self.knownEncoding = None                   # 2.1 Unicode: till Open or Save
    self.text.focus()                           # else must click in text
    if loadFirst:
        self.update()                           # 2.1: else @ line 2; see book
        self.onOpen(loadFirst, loadEncode)

def start(self):                                # run by GuiMaker.__init__
    self.menuBar = [                            # configure menu/toolbar
        ('File', 0,                             # a GuiMaker menu def tree
             [('Open...',    0, self.onOpen),   # build in method for self
              ('Save',       0, self.onSave),   # label, shortcut, callback
              ('Save As...', 5, self.onSaveAs),
              ('New',        0, self.onNew),
              'separator',
              ('Quit...',    0, self.onQuit)]
        ),
        ('Edit', 0,
             [('Undo',       0, self.onUndo),
              ('Redo',       0, self.onRedo),
              'separator',
              ('Cut',        0, self.onCut),
              ('Copy',       1, self.onCopy),
              ('Paste',      0, self.onPaste),
              'separator',
              ('Delete',     0, self.onDelete),
              ('Select All', 0, self.onSelectAll)]
        ),
        ('Search', 0,
             [('Goto...',    0, self.onGoto),
              ('Find...',    0, self.onFind),
              ('Refind',     0, self.onRefind),
              ('Change...',  0, self.onChange),
              ('Grep...',    3, self.onGrep)]
        ),
        ('Tools', 0,
             [('Pick Font...', 6, self.onPickFont),
              ('Font List',    0, self.onFontList),
              'separator',
              ('Pick Bg...',   3, self.onPickBg),
              ('Pick Fg...',   0, self.onPickFg),
              ('Color List',   0, self.onColorList),
              'separator',
              ('Info...',      0, self.onInfo),
              ('Clone',        1, self.onClone),
              ('Run Code',     0, self.onRunCode)]
        )]
    self.toolBar = [
        ('Save',  self.onSave,   {'side': LEFT}),
        ('Cut',   self.onCut,    {'side': LEFT}),
        ('Copy',  self.onCopy,   {'side': LEFT}),
        ('Paste', self.onPaste,  {'side': LEFT}),
        ('Find',  self.onRefind, {'side': LEFT}),
        ('Help',  self.help,     {'side': RIGHT}),
        ('Quit',  self.onQuit,   {'side': RIGHT})]

def makeWidgets(self):                          # run by GuiMaker.__init__
    name = Label(self, bg='black', fg='white')  # add below menu, above tool
    name.pack(side=TOP, fill=X)                 # menu/toolbars are packed
                                                # GuiMaker frame packs itself
    vbar  = Scrollbar(self)
    hbar  = Scrollbar(self, orient='horizontal')
    text  = Text(self, padx=5, wrap='none')        # disable line wrapping
    text.config(undo=1, autoseparators=1)          # 2.0, default is 0, 1

    vbar.pack(side=RIGHT,  fill=Y)
    hbar.pack(side=BOTTOM, fill=X)                 # pack text last
    text.pack(side=TOP,    fill=BOTH, expand=YES)  # else sbars clipped

    text.config(yscrollcommand=vbar.set)    # call vbar.set on text move
    text.config(xscrollcommand=hbar.set)
    vbar.config(command=text.yview)         # call text.yview on scroll move
    hbar.config(command=text.xview)         # or hbar['command']=text.xview

    # 2.0: apply user configs or defaults
    startfont = configs.get('font', self.fonts[0])
    startbg   = configs.get('bg',   self.colors[0]['bg'])
    startfg   = configs.get('fg',   self.colors[0]['fg'])
    text.config(font=startfont, bg=startbg, fg=startfg)
    if 'height' in configs: text.config(height=configs['height'])
    if 'width'  in configs: text.config(width =configs['width'])
    self.text = text
    self.filelabel = name


############################################################################
# File menu commands
############################################################################

def my_askopenfilename(self):      # objects remember last result dir/file
    if not self.openDialog:
       self.openDialog = Open(initialdir=self.startfiledir,
                              filetypes=self.ftypes)
    return self.openDialog.show()

def my_asksaveasfilename(self):    # objects remember last result dir/file
    if not self.saveDialog:
       self.saveDialog = SaveAs(initialdir=self.startfiledir,
                                filetypes=self.ftypes)
    return self.saveDialog.show()

def onOpen(self, loadFirst='', loadEncode=''):
    """
    2.1: total rewrite for Unicode support; open in text mode with
    an encoding passed in, input from the user, in textconfig, or
    platform default, or open as binary bytes for arbitrary Unicode
    encodings as last resort and drop r in Windows end-lines if
    present so text displays normally; content fetches are returned
    as str, so need to  encode on saves: keep encoding used here;

    tests if file is okay ahead of time to try to avoid opens;
    we could also load and manually decode bytes to str to avoid
    multiple open attempts, but this is unlikely to try all cases;

    encoding behavior is configurable in the local textConfig.py:
    1) tries known type first if passed in by client (email charsets)
    2) if opensAskUser True, try user input next (prefill wih defaults)
    3) if opensEncoding nonempty, try this encoding next: 'latin-1', etc.
    4) tries sys.getdefaultencoding() platform default next
    5) uses binary mode bytes and Tk policy as the last resort
    """

    if self.text_edit_modified():    # 2.0
        if not askyesno('PyEdit', 'Text has changed: discard changes?'):
            return

    file = loadFirst or self.my_askopenfilename()
    if not file:
        return

    if not os.path.isfile(file):
        showerror('PyEdit', 'Could not open file '   file)
        return

    # try known encoding if passed and accurate (e.g., email)
    text = None     # empty file = '' = False: test for None!
    if loadEncode:
        try:
            text = open(file, 'r', encoding=loadEncode).read()
            self.knownEncoding = loadEncode
        except (UnicodeError, LookupError, IOError):         # lookup: bad name
            pass

    # try user input, prefill with next choice as default
    if text == None and self.opensAskUser:
        self.update()  # else dialog doesn't appear in rare cases
        askuser = askstring('PyEdit', 'Enter Unicode encoding for open',
                            initialvalue=(self.opensEncoding or
                                          sys.getdefaultencoding() or ''))
        self.text.focus() # else must click
        if askuser:
            try:
                text = open(file, 'r', encoding=askuser).read()
                self.knownEncoding = askuser
            except (UnicodeError, LookupError, IOError):
                pass

    # try config file (or before ask user?)
    if text == None and self.opensEncoding:
        try:
            text = open(file, 'r', encoding=self.opensEncoding).read()
            self.knownEncoding = self.opensEncoding
        except (UnicodeError, LookupError, IOError):
            pass

    # try platform default (utf-8 on windows; try utf8 always?)
    if text == None:
        try:
            text = open(file, 'r', encoding=sys.getdefaultencoding()).read()
            self.knownEncoding = sys.getdefaultencoding()
        except (UnicodeError, LookupError, IOError):
            pass

    # last resort: use binary bytes and rely on Tk to decode
    if text == None:
        try:
            text = open(file, 'rb').read()         # bytes for Unicode
            text = text.replace(b'rn', b'n')    # for display, saves
            self.knownEncoding = None
        except IOError:
            pass

    if text == None:
        showerror('PyEdit', 'Could not decode and open file '   file)
    else:
        self.setAllText(text)
        self.setFileName(file)
        self.text.edit_reset()             # 2.0: clear undo/redo stks
        self.text.edit_modified(0)         # 2.0: clear modified flag

def onSave(self):
    self.onSaveAs(self.currfile)  # may be None

def onSaveAs(self, forcefile=None):
    """
    2.1: total rewrite for Unicode support: Text content is always
    returned as a str, so we must deal with encodings to save to
    a file here, regardless of open mode of the output file (binary
    requires bytes, and text must encode); tries the encoding used
    when opened or saved (if known), user input, config file setting,
    and platform default last; most users can use platform default;

    retains successful encoding name here for next save, because this
    may be the first Save after New or a manual text insertion;  Save
    and SaveAs may both use last known encoding, per config file (it
    probably should be used for Save, but SaveAs usage is unclear);
    gui prompts are prefilled with the known encoding if there is one;

    does manual text.encode() to avoid creating file; text mode files
    perform platform specific end-line conversion: Windows r dropped
    if present on open by text mode (auto) and binary mode (manually);
    if manual content inserts, must delete r else duplicates here;
    knownEncoding=None before first Open or Save, after New, if binary Open;

    encoding behavior is configurable in the local textConfig.py:
    1) if savesUseKnownEncoding > 0, try encoding from last open or save
    2) if savesAskUser True, try user input next (prefill with known?)
    3) if savesEncoding nonempty, try this encoding next: 'utf-8', etc
    4) tries sys.getdefaultencoding() as a last resort
    """

    filename = forcefile or self.my_asksaveasfilename()
    if not filename:
        return

    text = self.getAllText()      # 2.1: a str string, with n eolns,
    encpick = None                # even if read/inserted as bytes

    # try known encoding at latest Open or Save, if any
    if self.knownEncoding and (                                  # enc known?
       (forcefile     and self.savesUseKnownEncoding >= 1) or    # on Save?
       (not forcefile and self.savesUseKnownEncoding >= 2)):     # on SaveAs?
        try:
            text.encode(self.knownEncoding)
            encpick = self.knownEncoding
        except UnicodeError:
            pass

    # try user input, prefill with known type, else next choice
    if not encpick and self.savesAskUser:
        self.update()  # else dialog doesn't appear in rare cases
        askuser = askstring('PyEdit', 'Enter Unicode encoding for save',
                            initialvalue=(self.knownEncoding or
                                          self.savesEncoding or
                                          sys.getdefaultencoding() or ''))
        self.text.focus() # else must click
        if askuser:
            try:
                text.encode(askuser)
                encpick = askuser
            except (UnicodeError, LookupError):    # LookupError:  bad name
                pass                               # UnicodeError: can't encode

    # try config file
    if not encpick and self.savesEncoding:
        try:
            text.encode(self.savesEncoding)
            encpick = self.savesEncoding
        except (UnicodeError, LookupError):
            pass

    # try platform default (utf8 on windows)
    if not encpick:
        try:
            text.encode(sys.getdefaultencoding())
            encpick = sys.getdefaultencoding()
        except (UnicodeError, LookupError):
            pass

    # open in text mode for endlines   encoding
    if not encpick:
        showerror('PyEdit', 'Could not encode for file '   filename)
    else:
        try:
            file = open(filename, 'w', encoding=encpick)
            file.write(text)
            file.close()
        except:
            showerror('PyEdit', 'Could not write file '   filename)
        else:
            self.setFileName(filename)          # may be newly created
            self.text.edit_modified(0)          # 2.0: clear modified flag
            self.knownEncoding = encpick        # 2.1: keep enc for next save
                                                # don't clear undo/redo stks!
def onNew(self):
    """
    start editing a new file from scratch in current window;
    see onClone to pop-up a new independent edit window instead;
    """
    if self.text_edit_modified():    # 2.0
        if not askyesno('PyEdit', 'Text has changed: discard changes?'):
            return
    self.setFileName(None)
    self.clearAllText()
    self.text.edit_reset()                 # 2.0: clear undo/redo stks
    self.text.edit_modified(0)             # 2.0: clear modified flag
    self.knownEncoding = None              # 2.1: Unicode type unknown

def onQuit(self):
    """
    on Quit menu/toolbar select and wm border X button in toplevel windows;
    2.1: don't exit app if others changed;  2.0: don't ask if self unchanged;
    moved to the top-level window classes at the end since may vary per usage:
    a Quit in GUI might quit() to exit, destroy() just one Toplevel, Tk, or
    edit frame, or not be provided at all when run as an attached component;
    check self for changes, and if might quit(), main windows should check
    other windows in the process-wide list to see if they have changed too;
    """
    assert False, 'onQuit must be defined in window-specific sublass'

def text_edit_modified(self):
    """
    2.1: this now works! seems to have been a bool result type issue in tkinter;
    2.0: self.text.edit_modified() broken in Python 2.4: do manually for now;
    """
    return self.text.edit_modified()
   #return self.tk.call((self.text._w, 'edit')   ('modified', None))


############################################################################
# Edit menu commands
############################################################################

def onUndo(self):                           # 2.0
    try:                                    # tk8.4 keeps undo/redo stacks
        self.text.edit_undo()               # exception if stacks empty
    except TclError:                        # menu tear-offs for quick undo
        showinfo('PyEdit', 'Nothing to undo')

def onRedo(self):                           # 2.0: redo an undone
    try:
        self.text.edit_redo()
    except TclError:
        showinfo('PyEdit', 'Nothing to redo')

def onCopy(self):                           # get text selected by mouse, etc.
    if not self.text.tag_ranges(SEL):       # save in cross-app clipboard
        showerror('PyEdit', 'No text selected')
    else:
        text = self.text.get(SEL_FIRST, SEL_LAST)
        self.clipboard_clear()
        self.clipboard_append(text)

def onDelete(self):                         # delete selected text, no save
    if not self.text.tag_ranges(SEL):
        showerror('PyEdit', 'No text selected')
    else:
        self.text.delete(SEL_FIRST, SEL_LAST)

def onCut(self):
    if not self.text.tag_ranges(SEL):
        showerror('PyEdit', 'No text selected')
    else:
        self.onCopy()                       # save and delete selected text
        self.onDelete()

def onPaste(self):
    try:
        text = self.selection_get(selection='CLIPBOARD')
    except TclError:
        showerror('PyEdit', 'Nothing to paste')
        return
    self.text.insert(INSERT, text)          # add at current insert cursor
    self.text.tag_remove(SEL, '1.0', END)
    self.text.tag_add(SEL, INSERT '-�' % len(text), INSERT)
    self.text.see(INSERT)                   # select it, so it can be cut

def onSelectAll(self):
    self.text.tag_add(SEL, '1.0', END '-1c')   # select entire text
    self.text.mark_set(INSERT, '1.0')          # move insert point to top
    self.text.see(INSERT)                      # scroll to top


############################################################################
# Search menu commands
############################################################################

def onGoto(self, forceline=None):
    line = forceline or askinteger('PyEdit', 'Enter line number')
    self.text.update()
    self.text.focus()
    if line is not None:
        maxindex = self.text.index(END '-1c')
        maxline  = int(maxindex.split('.')[0])
        if line > 0 and line <= maxline:
            self.text.mark_set(INSERT, '%d.0' % line)      # goto line
            self.text.tag_remove(SEL, '1.0', END)          # delete selects
            self.text.tag_add(SEL, INSERT, 'insert   1l')  # select line
            self.text.see(INSERT)                          # scroll to line
        else:
            showerror('PyEdit', 'Bad line number')

def onFind(self, lastkey=None):
    key = lastkey or askstring('PyEdit', 'Enter search string')
    self.text.update()
    self.text.focus()
    self.lastfind = key
    if key:                                                    # 2.0: nocase
        nocase = configs.get('caseinsens', True)               # 2.0: config
        where = self.text.search(key, INSERT, END, nocase=nocase)
        if not where:                                          # don't wrap
            showerror('PyEdit', 'String not found')
        else:
            pastkey = where   ' �' % len(key)           # index past key
            self.text.tag_remove(SEL, '1.0', END)         # remove any sel
            self.text.tag_add(SEL, where, pastkey)        # select key
            self.text.mark_set(INSERT, pastkey)           # for next find
            self.text.see(where)                          # scroll display

def onRefind(self):
    self.onFind(self.lastfind)

def onChange(self):
    """
    non-modal find/change dialog
    2.1: pass per-dialog inputs to callbacks, may be > 1 change dialog open
    """
    new = Toplevel(self)
    new.title('PyEdit - change')
    Label(new, text='Find text?', relief=RIDGE, width=15).grid(row=0, column=0)
    Label(new, text='Change to?', relief=RIDGE, width=15).grid(row=1, column=0)
    entry1 = Entry(new)
    entry2 = Entry(new)
    entry1.grid(row=0, column=1, sticky=EW)
    entry2.grid(row=1, column=1, sticky=EW)

    def onFind():                         # use my entry in enclosing scope
        self.onFind(entry1.get())         # runs normal find dialog callback

    def onApply():
        self.onDoChange(entry1.get(), entry2.get())

    Button(new, text='Find',  command=onFind ).grid(row=0, column=2, sticky=EW)
    Button(new, text='Apply', command=onApply).grid(row=1, column=2, sticky=EW)
    new.columnconfigure(1, weight=1)      # expandable entries

def onDoChange(self, findtext, changeto):
    # on Apply in change dialog: change and refind
    if self.text.tag_ranges(SEL):                      # must find first
        self.text.delete(SEL_FIRST, SEL_LAST)
        self.text.insert(INSERT, changeto)             # deletes if empty
        self.text.see(INSERT)
        self.onFind(findtext)                          # goto next appear
        self.text.update()                             # force refresh

def onGrep(self):
    """
    new in version 2.1: threaded external file search;
    search matched filenames in directory tree for string;
    listbox clicks open matched file at line of occurrence;

    search is threaded so the GUI remains active and is not
    blocked, and to allow multiple greps to overlap in time;
    could use threadtools, but avoid loop in no active grep;

    grep Unicode policy: text files content in the searched tree
    might be in any Unicode encoding: we don't ask about each (as
    we do for opens), but allow the encoding used for the entire
    tree to be input, preset it to the platform filesystem or
    text default, and skip files that fail to decode; in worst
    cases, users may need to run grep N times if N encodings might
    exist;  else opens may raise exceptions, and opening in binary
    mode might fail to match encoded text against search string;

    TBD: better to issue an error if any file fails to decode?
    but utf-16 2-bytes/char format created in Notepad may decode
    without error per utf-8, and search strings won't be found;
    TBD: could allow input of multiple encoding names, split on
    comma, try each one for every file, without open loadEncode?
    """
    from PP4E.Gui.ShellGui.formrows import makeFormRow

    # nonmodal dialog: get dirnname, filenamepatt, grepkey
    popup = Toplevel()
    popup.title('PyEdit - grep')
    var1 = makeFormRow(popup, label='Directory root',   width=18, browse=False)
    var2 = makeFormRow(popup, label='Filename pattern', width=18, browse=False)
    var3 = makeFormRow(popup, label='Search string',    width=18, browse=False)
    var4 = makeFormRow(popup, label='Content encoding', width=18, browse=False)
    var1.set('.')      # current dir
    var2.set('*.py')   # initial values
    var4.set(sys.getdefaultencoding())    # for file content, not filenames
    cb = lambda: self.onDoGrep(var1.get(), var2.get(), var3.get(), var4.get())
    Button(popup, text='Go',command=cb).pack()

def onDoGrep(self, dirname, filenamepatt, grepkey, encoding):
    """
    on Go in grep dialog: populate scrolled list with matches
    tbd: should producer thread be daemon so it dies with app?
    """
    import threading, queue

    # make non-modal un-closeable dialog
    mypopup = Tk()
    mypopup.title('PyEdit - grepping')
    status = Label(mypopup, text='Grep thread searching for: %r...' % grepkey)
    status.pack(padx=20, pady=20)
    mypopup.protocol('WM_DELETE_WINDOW', lambda: None)  # ignore X close

    # start producer thread, consumer loop
    myqueue = queue.Queue()
    threadargs = (filenamepatt, dirname, grepkey, encoding, myqueue)
    threading.Thread(target=self.grepThreadProducer, args=threadargs).start()
    self.grepThreadConsumer(grepkey, encoding, myqueue, mypopup)

def grepThreadProducer(self, filenamepatt, dirname, grepkey, encoding, myqueue):
    """
    in a non-GUI parallel thread: queue find.find results list;
    could also queue matches as found, but need to keep window;
    file content and file names may both fail to decode here;

    TBD: could pass encoded bytes to find() to avoid filename
    decoding excs in os.walk/listdir, but which encoding to use:
    sys.getfilesystemencoding() if not None?  see also Chapter6
    footnote issue: 3.1 fnmatch always converts bytes per Latin-1;
    """
    from PP4E.Tools.find import find
    matches = []
    try:
        for filepath in find(pattern=filenamepatt, startdir=dirname):
            try:
                textfile = open(filepath, encoding=encoding)
                for (linenum, linestr) in enumerate(textfile):
                    if grepkey in linestr:
                        msg = '%s@%d  [%s]' % (filepath, linenum   1, linestr)
                        matches.append(msg)
            except UnicodeError as X:
                print('Unicode error in:', filepath, X)       # eg: decode, bom
            except IOError as X:
                print('IO error in:', filepath, X)            # eg: permission
    finally:
        myqueue.put(matches)      # stop consumer loop on find excs: filenames?

def grepThreadConsumer(self, grepkey, encoding, myqueue, mypopup):
    """
    in the main GUI thread: watch queue for results or [];
    there may be multiple active grep threads/loops/queues;
    there may be other types of threads/checkers in process,
    especially when PyEdit is attached component (PyMailGUI);
    """
    import queue
    try:
        matches = myqueue.get(block=False)
    except queue.Empty:
        myargs  = (grepkey, encoding, myqueue, mypopup)
        self.after(250, self.grepThreadConsumer, *myargs)
    else:
        mypopup.destroy()     # close status
        self.update()         # erase it now
        if not matches:
            showinfo('PyEdit', 'Grep found no matches for: %r' % grepkey)
        else:
            self.grepMatchesList(matches, grepkey, encoding)

def grepMatchesList(self, matches, grepkey, encoding):
    """
    populate list after successful matches;
    we already know Unicode encoding from the search: use
    it here when filename clicked, so open doesn't ask user;
    """
    from PP4E.Gui.Tour.scrolledlist import ScrolledList
    print('Matches for %s: %s' % (grepkey, len(matches)))

    # catch list double-click
    class ScrolledFilenames(ScrolledList):
        def runCommand(self, selection):
            file, line = selection.split('  [', 1)[0].split('@')
            editor = TextEditorMainPopup(
                loadFirst=file, winTitle=' grep match', loadEncode=encoding)
            editor.onGoto(int(line))
            editor.text.focus_force()   # no, really

    # new non-modal widnow
    popup = Tk()
    popup.title('PyEdit - grep matches: %r (%s)' % (grepkey, encoding))
    ScrolledFilenames(parent=popup, options=matches)


############################################################################
# Tools menu commands
############################################################################

def onFontList(self):
    self.fonts.append(self.fonts[0])           # pick next font in list
    del self.fonts[0]                          # resizes the text area
    self.text.config(font=self.fonts[0])

def onColorList(self):
    self.colors.append(self.colors[0])         # pick next color in list
    del self.colors[0]                         # move current to end
    self.text.config(fg=self.colors[0]['fg'], bg=self.colors[0]['bg'])

def onPickFg(self):
    self.pickColor('fg')                       # added on 10/02/00

def onPickBg(self):                            # select arbitrary color
    self.pickColor('bg')                       # in standard color dialog

def pickColor(self, part):                     # this is too easy
    (triple, hexstr) = askcolor()
    if hexstr:
        self.text.config(**{part: hexstr})

def onInfo(self):
    """
    pop-up dialog giving text statistics and cursor location;
    caveat (2.1): Tk insert position column counts a tab as one
    character: translate to next multiple of 8 to match visual?
    """
    text  = self.getAllText()                  # added on 5/3/00 in 15 mins
    bytes = len(text)                          # words uses a simple guess:
    lines = len(text.split('n'))              # any separated by whitespace
    words = len(text.split())                  # 3.x: bytes is really chars
    index = self.text.index(INSERT)            # str is unicode code points
    where = tuple(index.split('.'))
    showinfo('PyEdit Information',
             'Current location:nn'  
             'line:t%sncolumn:t%snn' % where  
             'File text statistics:nn'  
             'chars:t%dnlines:t%dnwords:t%dn' % (bytes, lines, words))

def onClone(self, makewindow=True):
    """
    open a new edit window without changing one already open (onNew);
    inherits quit and other behavior of the window that it clones;
    2.1: subclass must redefine/replace this if makes its own popup,
    else this creates a bogus extra window here which will be empty;
    """
    if not makewindow:
         new = None                 # assume class makes its own window
    else:
         new = Toplevel()           # a new edit window in same process
    myclass = self.__class__        # instance's (lowest) class object
    myclass(new)                    # attach/run instance of my class

def onRunCode(self, parallelmode=True):
    """
    run Python code being edited--not an IDE, but handy;
    tries to run in file's dir, not cwd (may be PP4E root);
    inputs and adds command-line arguments for script files;

    code's stdin/out/err = editor's start window, if any:
    run with a console window to see code's print outputs;
    but parallelmode uses start to open a DOS box for I/O;
    module search path will include '.' dir where started;
    in non-file mode, code's Tk root may be PyEdit's window;
    subprocess or multiprocessing modules may work here too;

    2.1: fixed to use base file name after chdir, not path;
    2.1: use StartArgs to allow args in file mode on Windows;
    2.1: run an update() after 1st dialog else 2nd dialog
    sometimes does not appear in rare cases;
    """
    def askcmdargs():
        return askstring('PyEdit', 'Commandline arguments?') or ''

    from PP4E.launchmodes import System, Start, StartArgs, Fork
    filemode = False
    thefile  = str(self.getFileName())
    if os.path.exists(thefile):
        filemode = askyesno('PyEdit', 'Run from file?')
        self.update()                                   # 2.1: run update()
    if not filemode:                                    # run text string
        cmdargs   = askcmdargs()
        namespace = {'__name__': '__main__'}            # run as top-level
        sys.argv  = [thefile]   cmdargs.split()         # could use threads
        exec(self.getAllText()   'n', namespace)       # exceptions ignored
    elif self.text_edit_modified():                     # 2.0: changed test
        showerror('PyEdit', 'Text changed: you must save before run')
    else:
        cmdargs = askcmdargs()
        mycwd   = os.getcwd()                           # cwd may be root
        dirname, filename = os.path.split(thefile)      # get dir, base
        os.chdir(dirname or mycwd)                      # cd for filenames
        thecmd  = filename   ' '   cmdargs              # 2.1: not theFile
        if not parallelmode:                            # run as file
            System(thecmd, thecmd)()                    # block editor
        else:
            if sys.platform[:3] == 'win':               # spawn in parallel
                run = StartArgs if cmdargs else Start   # 2.1: support args
                run(thecmd, thecmd)()                   # or always Spawn
            else:
                Fork(thecmd, thecmd)()                  # spawn in parallel
        os.chdir(mycwd)                                 # go back to my dir

def onPickFont(self):
    """
    2.0 non-modal font spec dialog
    2.1: pass per-dialog inputs to callback, may be > 1 font dialog open
    """
    from formrows import makeFormRow
    popup = Toplevel(self)
    popup.title('PyEdit - font')
    var1 = makeFormRow(popup, label='Family', browse=False)
    var2 = makeFormRow(popup, label='Size',   browse=False)
    var3 = makeFormRow(popup, label='Style',  browse=False)
    var1.set('courier')
    var2.set('12')              # suggested vals
    var3.set('bold italic')     # see pick list for valid inputs
    Button(popup, text='Apply', command=
           lambda: self.onDoFont(var1.get(), var2.get(), var3.get())).pack()

def onDoFont(self, family, size, style):
    try:
        self.text.config(font=(family, int(size), style))
    except:
        showerror('PyEdit', 'Bad font specification')


############################################################################
# Utilities, useful outside this class
############################################################################

def isEmpty(self):
    return not self.getAllText()

def getAllText(self):
    return self.text.get('1.0', END '-1c')    # extract text as str string
def setAllText(self, text):
    """
    caller: call self.update() first if just packed, else the
    initial position may be at line 2, not line 1 (2.1; Tk bug?)
    """
    self.text.delete('1.0', END)              # store text string in widget
    self.text.insert(END, text)               # or '1.0'; text=bytes or str
    self.text.mark_set(INSERT, '1.0')         # move insert point to top
    self.text.see(INSERT)                     # scroll to top, insert set
def clearAllText(self):
    self.text.delete('1.0', END)              # clear text in widget

def getFileName(self):
    return self.currfile
def setFileName(self, name):                  # see also: onGoto(linenum)
    self.currfile = name  # for save
    self.filelabel.config(text=str(name))

def setKnownEncoding(self, encoding='utf-8'): # 2.1: for saves if inserted
    self.knownEncoding = encoding             # else saves use config, ask?

def setBg(self, color):
    self.text.config(bg=color)                # to set manually from code
def setFg(self, color):
    self.text.config(fg=color)                # 'black', hexstring
def setFont(self, font):
    self.text.config(font=font)               # ('family', size, 'style')

def setHeight(self, lines):                   # default = 24h x 80w
    self.text.config(height=lines)            # may also be from textCongif.py
def setWidth(self, chars):
    self.text.config(width=chars)

def clearModified(self):
    self.text.edit_modified(0)                # clear modified flag
def isModified(self):
    return self.text_edit_modified()          # changed since last reset?

def help(self):
    showinfo('About PyEdit', helptext % ((Version,)*2))

################################################################################

Ready-to-use editor classes

mixes in a GuiMaker Frame subclass which builds menu and toolbars

these classes are common use cases, but other configurations are possible;

call TextEditorMain().mainloop() to start PyEdit as a standalone program;

redefine/extend onQuit in a subclass to catch exit or destroy (see PyView);

caveat: could use windows.py for icons, but quit protocol is custom here.

################################################################################

-------------------------------------------------------------------------------

2.1: on quit(), don't silently exit entire app if any other changed edit

windows are open in the process - changes would be lost because all other

windows are closed too, including multiple Tk editor parents; uses a list

to keep track of all PyEdit window instances open in process; this may be

too broad (if we destroy() instead of quit(), need only check children

of parent being destroyed), but better to err on side of being too inclusive;

onQuit moved here because varies per window type and is not present for all;

assumes a TextEditorMainPopup is never a parent to other editor windows -

Toplevel children are destroyed with their parents; this does not address

closes outside the scope of PyEdit classes here (tkinter quit is available

on every widget, and any widget type may be a Toplevel parent!); client is

responsible for checking for editor content changes in all uncovered cases;

note that tkinter's <Destroy> bind event won't help here, because its callback

cannot run GUI operations such as text change tests and fetches - see the

book and destroyer.py for more details on this event;

-------------------------------------------------------------------------------

###################################

when text editor owns the window

###################################

class TextEditorMain(TextEditor, GuiMakerWindowMenu): """ main PyEdit windows that quit() to exit app on a Quit in GUI, and build a menu on a window; parent may be default Tk, explicit Tk, or Toplevel: parent must be a window, and probably should be a Tk so this isn't silently destroyed and closed with a parent; all main PyEdit windows check all other PyEdit windows open in the process for changes on a Quit in the GUI, since a quit() here will exit the entire app; the editor's frame need not occupy entire window (may have other parts: see PyView), but its Quit ends program; onQuit is run for Quit in toolbar or File menu, as well as window border X; """ def init(self, parent=None, loadFirst='', loadEncode=''): # editor fills whole parent window GuiMaker.init(self, parent) # use main window menus TextEditor.init(self, loadFirst, loadEncode) # GuiMaker frame packs self self.master.title('PyEdit ' Version) # title, wm X if standalone self.master.iconname('PyEdit') self.master.protocol('WM_DELETE_WINDOW', self.onQuit) TextEditor.editwindows.append(self)

代码语言:javascript复制
def onQuit(self):                              # on a Quit request in the GUI
    close = not self.text_edit_modified()      # check self, ask?, check others
    if not close:
        close = askyesno('PyEdit', 'Text changed: quit and discard changes?')
    if close:
        windows = TextEditor.editwindows
        changed = [w for w in windows if w != self and w.text_edit_modified()]
        if not changed:
            GuiMaker.quit(self) # quit ends entire app regardless of widget type
        else:
            numchange = len(changed)
            verify = '%s other edit window%s changed: quit and discard anyhow?'
            verify = verify % (numchange, 's' if numchange > 1 else '')
            if askyesno('PyEdit', verify):
                GuiMaker.quit(self)

class TextEditorMainPopup(TextEditor, GuiMakerWindowMenu): """ popup PyEdit windows that destroy() to close only self on a Quit in GUI, and build a menu on a window; makes own Toplevel parent, which is child to default Tk (for None) or other passed-in window or widget (e.g., a frame); adds to list so will be checked for changes if any PyEdit main window quits; if any PyEdit main windows will be created, parent of this should also be a PyEdit main window's parent so this is not closed silently while being tracked; onQuit is run for Quit in toolbar or File menu, as well as window border X; """ def init(self, parent=None, loadFirst='', winTitle='', loadEncode=''): # create own window self.popup = Toplevel(parent) GuiMaker.init(self, self.popup) # use main window menus TextEditor.init(self, loadFirst, loadEncode) # a frame in a new popup assert self.master == self.popup self.popup.title('PyEdit ' Version winTitle) self.popup.iconname('PyEdit') self.popup.protocol('WM_DELETE_WINDOW', self.onQuit) TextEditor.editwindows.append(self)

代码语言:javascript复制
def onQuit(self):
    close = not self.text_edit_modified()
    if not close:
        close = askyesno('PyEdit', 'Text changed: quit and discard changes?')
    if close:
        self.popup.destroy()                       # kill this window only
        TextEditor.editwindows.remove(self)        # (plus any child windows)

def onClone(self):
    TextEditor.onClone(self, makewindow=False)     # I make my own pop-up

#########################################

when editor embedded in another window

#########################################

class TextEditorComponent(TextEditor, GuiMakerFrameMenu): """ attached PyEdit component frames with full menu/toolbar options, which run a destroy() on a Quit in the GUI to erase self only; a Quit in the GUI verifies if any changes in self (only) here; does not intercept window manager border X: doesn't own window; does not add self to changes tracking list: part of larger app; """ def init(self, parent=None, loadFirst='', loadEncode=''): # use Frame-based menus GuiMaker.init(self, parent) # all menus, buttons on TextEditor.init(self, loadFirst, loadEncode) # GuiMaker must init 1st

代码语言:javascript复制
def onQuit(self):
    close = not self.text_edit_modified()
    if not close:
        close = askyesno('PyEdit', 'Text changed: quit and discard changes?')
    if close:
        self.destroy()   # erase self Frame but do not quit enclosing app

class TextEditorComponentMinimal(TextEditor, GuiMakerFrameMenu): """ attached PyEdit component frames without Quit and File menu options; on startup, removes Quit from toolbar, and either deletes File menu or disables all its items (possibly hackish, but sufficient); menu and toolbar structures are per-instance data: changes do not impact others; Quit in GUI never occurs, because it is removed from available options; """ def init(self, parent=None, loadFirst='', deleteFile=True, loadEncode=''): self.deleteFile = deleteFile GuiMaker.init(self, parent) # GuiMaker frame packs self TextEditor.init(self, loadFirst, loadEncode) # TextEditor adds middle

代码语言:javascript复制
def start(self):
    TextEditor.start(self)                         # GuiMaker start call
    for i in range(len(self.toolBar)):             # delete quit in toolbar
        if self.toolBar[i][0] == 'Quit':           # delete file menu items,
            del self.toolBar[i]                    # or just disable file
            break
    if self.deleteFile:
        for i in range(len(self.menuBar)):
            if self.menuBar[i][0] == 'File':
                del self.menuBar[i]
                break
    else:
        for (name, key, items) in self.menuBar:
            if name == 'File':
                items.append([1,2,3,4,6])

################################################################################

standalone program run

################################################################################

def testPopup(): # see PyView and PyMail for component tests root = Tk() TextEditorMainPopup(root) TextEditorMainPopup(root) Button(root, text='More', command=TextEditorMainPopup).pack(fill=X) Button(root, text='Quit', command=root.quit).pack(fill=X) root.mainloop()

def main(): # may be typed or clicked try: # or associated on Windows fname = sys.argv[1] # arg = optional filename except IndexError: # build in default Tk root fname = None TextEditorMain(loadFirst=fname).pack(expand=YES, fill=BOTH) # pack optional mainloop()

if name == 'main': # when run as a script #testPopup() main() # run .pyw for no DOS box

0 人点赞