PyEdit: A Text Editor Program/Object
PyEdit A Text Editor Program Object
In the last few decades, I've typed text into a lot of programs. Most were closed systems (I had to live with whatever decisions their designers made), and many ran on only one platform. The PyEdit program presented in this section does better on both counts: it implements a full-featured, graphical text editor program in roughly 470 lines of portable Python code (including whitespace and comments). Despite its size, PyEdit was sufficiently powerful and robust to serve as the primary tool used to code most examples in this book.
PyEdit supports all the usual mouse and keyboard text-editing operations: cut and paste, search and replace, open and save, and so on. But really, PyEdit is a bit more than just another text editor -- it is designed to be used as both a program and a library component, and can be run in a variety of roles:
Standalone mode
As a standalone text editor program, with or without the name of a file to be edited passed in on the command line. In this mode, PyEdit is roughly like other text-editing utility programs (e.g., Notepad on Windows), but also provides advanced functions such as running Python program code being edited, changing fonts and colors, and so on. More importantly, because it is coded in Python, PyEdit is easy to customize, and runs portably on Windows, X Windows, and Macintosh.
Pop-up mode
Within a new pop-up window, allowing an arbitrary number of copies to appear as pop-ups at once in a program. Because state information is stored in class instance attributes, each PyEdit object created operates independently. In this mode and the next, PyEdit serves as a library object for use in other scripts, not a canned application.
Embedded mode
As an attached component, to provide a text- editing widget for other GUIs. When attached, PyEdit uses a frame-based menu, and can optionally disable some of its menu options for an embedded role. For instance, PyView (later in this chapter) uses PyEdit in embedded mode this way to serve as a note editor for photos, and PyMail (in Chapter 11) attaches it to get an email text editor for free.
While such mixed-mode behavior may sound complicated to implement, most of PyEdit's modes are a natural by-product of coding GUIs with the class-based techniques we've seen in the last three chapters.
9.4.1 Running PyEdit
PyEdit sports lots of features, and the best way to learn how it works is to test drive it for yourself -- it can be run by starting the file textEditor.pyw, or from the PyDemo and PyGadget launcher bars described in the previous chapter (the launchers themselves live in the top level of the book examples directory tree). To give you a sampling of its interfaces, Figure 9-10 shows the main window's default appearance, after opening PyEdit's source code file.
Figure 9-10. PyEdit main window, editing itself
The main part of this window is a Text widget object, and if you read the last chapter's coverage of this widget, PyEdit text-editing operations will be familiar. It uses text marks, tags, and indexes, and implements cut-and-paste operations with the system clipboard so that PyEdit can paste data to and from other applications. Both vertical and horizontal scrollbars are cross-linked to the Text widget, to support movement through arbitrary files.
If PyEdit's menu and toolbars look familiar, they should -- it builds the main window with minimal code and appropriate clipping and expansion policies, by mixing in the GuiMaker class we met earlier in this chapter. The toolbar at the bottom contains shortcut buttons for operations I tend to use most often; if my preferences don't match yours, simply change the toolbar list in the source code to show the buttons you want (this is Python, after all). As usual for Tkinter menus, shortcut key combinations can be used to invoke menu options quickly, too -- press Alt plus all the underlined keys of entries along the path to the desired action.
PyEdit pops up a variety of modal and nonmodal dialogs, both standard and custom. Figure 9-11 shows the custom and nonmodal change dialog, along with a standard dialog used to display file statistics.
Figure 9-11. PyEdit with colors, font, and a few pop-ups
The main window here has been given new foreground and background colors (with the standard color selection dialog), and a new text font has been selected from a canned list in the script that users can change to suit their preferences (this is Python, after all). The standard file open and save selection dialogs in PyEdit use object-based interfaces to remember the last directory visited, so you don't have to renavigate there every time.
One of the more unique features of PyEdit is that it can actually run Python program code that you are editing. This isn't as hard as it may sound either -- because Python provides built-ins for both compiling and running code strings and launching programs, PyEdit simply has to make the right calls for this to work. For example, it's easy to code a simple-minded Python interpreter in Python (though you need a bit more to handle multiple-line statements), as shown in Example 9-13.
Example 9-13. PP2EGuiTextEditorsimpleshell.py
namespace= {} while 1: try: line = raw_input('>>> ') # single line statements only except EOFError: break else: exec line in namespace # or eval( ) and print result
Depending on the user's preference, PyEdit either does something similar to this to run code fetched from the text widget, or uses the launchmodes module we wrote at the end of Chapter 3 to run the code's file as an independent program. There are a variety of options in both schemes that you can customize as you like (this is Python, after all). See the onRunCode method for details, or simply edit and run some Python code on your own.
Figure 9-12 shows four independently started instances of PyEdit running with a variety of color schemes, sizes, and fonts. This figure also captures two PyEdit torn-off menus (lower right) and the PyEdit help pop-up (upper right). The edit windows' backgrounds are shades of yellow, blue, purple, and orange; use the Tools menu's Pick options to set colors as you like.
Figure 9-12. Multiple PyEdit sessions at work
Since these four PyEdit sessions are all editing Python source-coded text, you can run their contents with the Run Code option in the Tools pull-down menu. Code run from files is spawned independently; the standard streams of code run not from a file (i.e., fetched from the text widget itself) are mapped to the PyEdit session's console window. This isn't an IDE by any means; it's just something I added because I found it to be useful. It's nice to run code you're editing without fishing through directories.
One caveat before I turn you loose on the source code: PyEdit does not yet have an Undo button in this release. I don't use such a mode myself, and it's easy to undo cuts and pastes right after you've done them (simply paste back from the clipboard, or cut the pasted and selected text). Adding a general undo option would make for a fun exercise if you are so motivated. An interesting approach may be to subclass either the TextEditor class here or the Tkinter Text class itself. Such a subclass would record text operations on a limited-length list and run calls to back out of each logged operation on demand and in reverse. It could also be used to make PyEdit smarter about knowing when to ask about saving the file before it exits. By adding undo as an external subclass, exiting PyEdit code would not have to be instrumented to keep track of everything it does to the text. This is Python, after all.
9.4.2 PyEdit Source Code
The program in Example 9-14 consists of just two source files -- a .pyw that can be run on Windows to avoid the DOS console streams window pop-up, and a main .py that can be either run or imported. We need both because PyEdit is both script and library, and .pyw files can only be run, not imported (see Chapter 2 if you've forgotten what that implies).
Example 9-14. PP2EGuiTextEditor extEditor.pyw
################################################################# # run PyEdit without DOS console popup for os.system on Windows; # at present, ".pyw" files cannot be imported as modules; # if you want a file to be both a program that launches without # a dos console box on windows, and be imported from elsewhere, # use ".py" for the main file and import .py code from a ".pyw"; # execfile('textEditor.py') fails when run from another dir, # because the current working dir is the dir I'm run from; ################################################################# import textEditor # grab .py (or .pyc) file textEditor.main( ) # run top-level entry point
The module in Example 9-15 is PyEdit's implementation; the main classes used to start and embed a PyEdit object appear at the end of this file. Study this listing while you experiment with PyEdit, to learn about its features and techniques.
Example 9-15. PP2EGuiTextEditor extEditor.py
################################################################################
# PyEdit 1.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
# stand-alone program, and attached as a component to other GUIs.
# Also used by PyMail and PyView to edit mail and image file notes.
################################################################################
Version = '1.1'
from Tkinter import * # base widgets, constants
from tkFileDialog import * # standard dialogs
from tkMessageBox import *
from tkSimpleDialog import *
from tkColorChooser import askcolor
from string import split, atoi
from PP2E.Gui.Tools.guimaker import * # Frame + menu/toolbar builders
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'
import sys, os, string
FontScale = 0 # use bigger font on linux
if sys.platform[:3] != 'win': # and other non-windows boxes
FontScale = 3
class TextEditor: # mix with menu/toolbar Frame class
startfiledir = '.'
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 popup a listbox
('courier', 10+FontScale, 'italic'), # make bigger on linux
('times', 10+FontScale, 'normal'),
('helvetica', 10+FontScale, 'normal'),
('ariel', 10+FontScale, 'normal'),
('system', 10+FontScale, 'normal'),
('courier', 20+FontScale, 'normal')]
def __init__(self, loadFirst=''):
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.text.focus( ) # else must click in text
if loadFirst:
self.onOpen(loadFirst)
def start(self): # run by GuiMaker.__init__
self.menuBar = [ # configure menu/toolbar
('File', 0,
[('Open...', 0, self.onOpen),
('Save', 0, self.onSave),
('Save As...', 5, self.onSaveAs),
('New', 0, self.onNew),
'separator',
('Quit...', 0, self.onQuit)]
),
('Edit', 0,
[('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)]
),
('Tools', 0,
[('Font List', 0, self.onFontList),
('Pick Bg...', 4, 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
vbar = Scrollbar(self)
hbar = Scrollbar(self, orient='horizontal')
text = Text(self, padx=5, wrap='none')
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
text.config(font=self.fonts[0],
bg=self.colors[0]['bg'], fg=self.colors[0]['fg'])
self.text = text
self.filelabel = name
#####################
# Edit menu commands
#####################
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+'-%dc' % 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
######################
# 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 way too easy
(triple, hexstr) = askcolor( )
if hexstr:
apply(self.text.config, ( ), {part: hexstr})
def onInfo(self):
text = self.getAllText( ) # added on 5/3/00 in 15 mins
bytes = len(text) # words uses a simple guess:
lines = len(string.split(text, '
')) # any separated by whitespace
words = len(string.split(text))
index = self.text.index(INSERT)
where = tuple(string.split(index, '.'))
showinfo('PyEdit Information',
'Current location:
' +
'line: %s
column: %s
' % where +
'File text statistics:
' +
'bytes: %d
lines: %d
words: %d
' % (bytes, lines, words))
def onClone(self):
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=1):
"""
run Python code being edited--not an ide, but handy;
tries to run in file's dir, not cwd (may be pp2e root);
inputs and adds command-line arguments for script files;
code's stdin/out/err = editor's start window, if any;
but parallelmode uses start to open a dos box for i/o;
"""
from PP2E.launchmodes import System, Start, Fork
filemode = 0
thefile = str(self.getFileName( ))
cmdargs = askstring('PyEdit', 'Commandline arguments?') or ''
if os.path.exists(thefile):
filemode = askyesno('PyEdit', 'Run from file?')
if not filemode: # run text string
namespace = {'__name__': '__main__'} # run as top-level
sys.argv = [thefile] + string.split(cmdargs) # could use threads
exec self.getAllText( ) + '
' in namespace # exceptions ignored
elif askyesno('PyEdit', 'Text saved in file?'):
mycwd = os.getcwd( ) # cwd may be root
os.chdir(os.path.dirname(thefile) or mycwd) # cd for filenames
thecmd = thefile + ' ' + cmdargs
if not parallelmode: # run as file
System(thecmd, thecmd)( ) # block editor
else:
if sys.platform[:3] == 'win': # spawn in parallel
Start(thecmd, thecmd)( ) # or use os.spawnv
else:
Fork(thecmd, thecmd)( ) # spawn in parallel
os.chdir(mycwd)
#######################
# Search menu commands
#######################
def onGoto(self):
line = askinteger('PyEdit', 'Enter line number')
self.text.update( )
self.text.focus( )
if line is not None:
maxindex = self.text.index(END+'-1c')
maxline = atoi(split(maxindex, '.')[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:
where = self.text.search(key, INSERT, END) # don't wrap
if not where:
showerror('PyEdit', 'String not found')
else:
pastkey = where + '+%dc' % 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):
new = Toplevel(self)
Label(new, text='Find text:').grid(row=0, column=0)
Label(new, text='Change to:').grid(row=1, column=0)
self.change1 = Entry(new)
self.change2 = Entry(new)
self.change1.grid(row=0, column=1, sticky=EW)
self.change2.grid(row=1, column=1, sticky=EW)
Button(new, text='Find',
command=self.onDoFind).grid(row=0, column=2, sticky=EW)
Button(new, text='Apply',
command=self.onDoChange).grid(row=1, column=2, sticky=EW)
new.columnconfigure(1, weight=1) # expandable entrys
def onDoFind(self):
self.onFind(self.change1.get( )) # Find in change box
def onDoChange(self):
if self.text.tag_ranges(SEL): # must find first
self.text.delete(SEL_FIRST, SEL_LAST) # Apply in change
self.text.insert(INSERT, self.change2.get( )) # deletes if empty
self.text.see(INSERT)
self.onFind(self.change1.get( )) # goto next appear
self.text.update( ) # force refresh
#####################
# 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=''):
doit = self.isEmpty( ) or askyesno('PyEdit', 'Disgard text?')
if doit:
file = loadFirst or self.my_askopenfilename( )
if file:
try:
text = open(file, 'r').read( )
except:
showerror('PyEdit', 'Could not open file ' + file)
else:
self.setAllText(text)
self.setFileName(file)
def onSave(self):
self.onSaveAs(self.currfile) # may be None
def onSaveAs(self, forcefile=None):
file = forcefile or self.my_asksaveasfilename( )
if file:
text = self.getAllText( )
try:
open(file, 'w').write(text)
except:
showerror('PyEdit', 'Could not write file ' + file)
else:
self.setFileName(file) # may be newly created
def onNew(self):
doit = self.isEmpty( ) or askyesno('PyEdit', 'Disgard text?')
if doit:
self.setFileName(None)
self.clearAllText( )
def onQuit(self):
if askyesno('PyEdit', 'Really quit PyEdit?'):
self.quit( ) # Frame.quit via GuiMaker
####################################
# Others, 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 a string
def setAllText(self, text):
self.text.delete('1.0', END) # store text string in widget
self.text.insert(END, text) # or '1.0'
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):
self.currfile = name # for save
self.filelabel.config(text=str(name))
def help(self):
showinfo('About PyEdit',
'PyEdit version %s
October, 2000
'
'A text editor program
and object component
'
'written in Python/Tk.
Programming Python 2E
'
"O'Reilly & Associates" % Version)
##################################################################
# ready-to-use editor classes
# mix in a Frame subclass that builds menu/toolbars
##################################################################
# when editor owns the window
class TextEditorMain(TextEditor, GuiMakerWindowMenu): # add menu/toolbar maker
def __init__(self, parent=None, loadFirst=''): # when fills whole window
GuiMaker.__init__(self, parent) # use main window menus
TextEditor.__init__(self, loadFirst) # self has GuiMaker frame
self.master.title('PyEdit ' + Version) # title if stand-alone
self.master.iconname('PyEdit') # catch wm delete button
self.master.protocol('WM_DELETE_WINDOW', self.onQuit)
class TextEditorMainPopup(TextEditor, GuiMakerWindowMenu):
def __init__(self, parent=None, loadFirst=''):
self.popup = Toplevel(parent) # create own window
GuiMaker.__init__(self, self.popup) # use main window menus
TextEditor.__init__(self, loadFirst)
assert self.master == self.popup
self.popup.title('PyEdit ' + Version)
self.popup.iconname('PyEdit')
def quit(self):
self.popup.destroy( ) # kill this window only
# when embedded in another window
class TextEditorComponent(TextEditor, GuiMakerFrameMenu):
def __init__(self, parent=None, loadFirst=''): # use Frame-based menus
GuiMaker.__init__(self, parent) # all menus, buttons on
TextEditor.__init__(self, loadFirst) # GuiMaker must init 1st
class TextEditorComponentMinimal(TextEditor, GuiMakerFrameMenu):
def __init__(self, parent=None, loadFirst='', deleteFile=1):
self.deleteFile = deleteFile
GuiMaker.__init__(self, parent)
TextEditor.__init__(self, loadFirst)
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]; break # or just disable file
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])
# stand-alone 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:
fname = None
TextEditorMain(loadFirst=fname).pack(expand=YES, fill=BOTH)
mainloop( )
if __name__ == '__main__': # when run as a script
#testPopup( )
main( ) # run .pyw for no dos box
Категории