I l@ve RuBoard Previous Section Next Section

8.4 Text

It's been said that Tkinter's strongest points may be its text and canvas widgets. Both provide a remarkable amount of functionality. For instance, the Tkinter Text widget was powerful enough to implement the Grail web browser, discussed in Chapter 15; it supports complex font style settings, embedded images, and much more. The Tkinter Canvas widget, a general-purpose drawing device, has also been the basis of sophisticated image processing and visualization applications.

In Chapter 9, we'll put these two widgets to use to implement text editors (PyEdit), paint programs (PyDraw), clock GUIs (PyClock), and photo slideshows (PyView). For the purposes of this tour chapter, though, let's start out using these widgets in simpler ways. Example 8-10 implements a simple scrolled-text display, which knows how to fill its display with a text string or file.

Example 8-10. PP2E\Gui\Tour\scrolledtext.py
# a simple text or file viewer component

print 'PP2E scrolledtext'
from Tkinter import * 

class ScrolledText(Frame):
    def __init__(self, parent=None, text='', file=None):
        Frame.__init__(self, parent)
        self.pack(expand=YES, fill=BOTH)                 # make me expandable
        self.makewidgets()
        self.settext(text, file)
    def makewidgets(self):
        sbar = Scrollbar(self)
        text = Text(self, relief=SUNKEN)
        sbar.config(command=text.yview)                  # xlink sbar and text
        text.config(yscrollcommand=sbar.set)             # move one moves other
        sbar.pack(side=RIGHT, fill=Y)                    # pack first=clip last
        text.pack(side=LEFT, expand=YES, fill=BOTH)      # text clipped first
        self.text = text
    def settext(self, text='', file=None):
        if file: 
            text = open(file, 'r').read()
        self.text.delete('1.0', END)                     # delete current text
        self.text.insert('1.0', text)                    # add at line 1, col 0
        self.text.mark_set(INSERT, '1.0')                # set insert cursor
        self.text.focus()                                # save user a click
    def gettext(self):                                   # returns a string
        return self.text.get('1.0', END+'-1c')           # first through last
 
if __name__ == '__main__':
    root = Tk()
    try:
        st = ScrolledText(file=sys.argv[1])              # filename on cmdline
    except IndexError:
        st = ScrolledText(text='Words\ngo here')         # or not: 2 lines
    def show(event): print repr(st.gettext())            # show as raw string
    root.bind('<Key-Escape>', show)                      # esc = dump text
    root.mainloop()

Like the ScrolledList of Example 8-9, the ScrolledText object in this file is designed to be a reusable component, but can also be run standalone to display text file contents. Also like the last section, this script is careful to pack the scrollbar first so that it is cut out of the display last as the window shrinks, and arranges for the embedded Text object to expand in both directions as the window grows. When run with a filename argument, this script makes the window shown in Figure 8-15; it embeds a Text widget on the left, and a cross-linked Scrollbar on the right.

Figure 8-15. scrolledtext in action
figs/ppy2_0815.gif

Just for fun, I populated the text file displayed in the window with the following code and command lines (and not just because I happen to live near an infamous hotel in Colorado):

C:\...\PP2E\Gui\Tour>type temp.py
f = open('temp.txt', 'w')
for i in range(250):
    f.write('%03d)  All work and no play makes Jack a dull boy.\n' % i)
f.close()

C:\...\PP2E\Gui\Tour>python temp.py

C:\...\PP2E\Gui\Tour>python scrolledtext.py temp.txt
PP2E scrolledtext

To view a file, pass its name on the command line -- its text is automatically displayed in the new window. By default, it is shown in a non-fixed-width font, but we'll pass a font option to the text widget in the next example to change that.

Notice the "PP2E scrolledtext" message printed when this script runs. Because there is also a ScrolledText.py file in the standard Python distribution with a very different interface, the one here identifies itself when run or imported so you can tell which one you've got. If the standard one ever goes away, import the one listed here for a simple text browser, and adjust configuration calls to include a ".text" qualifier level (the library version subclasses Text, not Frame).

8.4.1 Programming the Text Widget

To understand how this script works at all, though, we have to detour into a few Text widget details here. Earlier we met the Entry and Message widgets, which address a subset of the Text widget's uses. The Text widget is much richer in both features and interfaces -- it supports both input and display of multiple lines of text, editing operations for both programs and interactive users, multiple fonts and colors, and much more. Text objects are created, configured, and packed just like any other widget, but they have properties all their own.

8.4.1.1 Text is a Python string

Although the Text widget is a powerful tool, its interface seems to boil down to two core concepts. First of all, the content of a Text widget is represented as a string in Python scripts, and multiple lines are separated with the normal \n line terminator. The string 'Words\ngo here', for instance, represents two lines when stored in or fetched from a Text widget; it would normally have a trailing \n too, but doesn't have to.

To help illustrate this point, this script binds the Escape key press to fetch and print the entire contents of the Text widget it embeds:

C:\...\PP2E\Gui\Tour>python scrolledtext.py
PP2E scrolledtext
'Words\012go here'
'Always look\012on the bright\012side of life\012'

When run with arguments, the script stores a file's contents in the text widget. When run without arguments, the script stuffs a simple literal string into the widget, displayed by the first Escape press output here (recall that \012 is the octal escape form of the \n line terminator). The second output here happens when pressing Escape in the shrunken window captured in Figure 8-16.

Figure 8-16. scrolledtext gets a positive outlook
figs/ppy2_0816.gif
8.4.1.2 String positions

The second key to understanding Text code has to do with the ways you specify a position in the text string. Like the listbox, text widgets allow you to specify such a position in a variety of ways. In Text, methods that expect a position to be passed in will accept an index, a mark, or a tag reference. Moreover, some special operations are invoked with predefined marks and tags -- the insert cursor is mark INSERT, and the current selection is tag SEL.

8.4.1.2.1 Text indexes

Because it is a multiple-line widget, Text indexes identify both a line and a column. For instance, consider the interfaces of the basic insert, delete, and fetch text operations used by this script:

self.text.insert('1.0', text)             # insert text at the start
self.text.delete('1.0', END)              # delete all current text
return self.text.get('1.0', END+'-1c')    # fetch first through last

In all of these, the first argument is an absolute index that refers to the start of the text string: string "1.0" means row 1, column (rows are numbered from 1 and columns from 0). An index "2.1" refers to the second character in the second row.

Like the listbox, text indexes can also be symbolic names: the END in the preceding delete call refers to the position just past the last character in the text string (it's a Tkinter variable preset to string "end"). Similarly, the symbolic index INSERT (really, string "insert") refers to the position immediately after the insert cursor -- the place where characters would appear if typed at the keyboard. Symbolic names like INSERT can also be called marks, described in a moment.

For added precision, you can add simple arithmetic extensions to index strings. The index expression END+'-1c' in the get call in the previous example, for instance, is really the string "end-1c", and refers to one character back from END. Because END points to just beyond the last character in the text string, this expression refers to the last character itself. The -1c extension effectively strips the trailing \n that this widget adds to its contents (and may add a blank line if saved in a file).

Similar index string extensions let you name characters ahead (+1c), lines ahead and behind (+2l, -2l), and specify things like word and line starts around an index (lineend, wordstart). Indexes show up in most Text widget calls.

8.4.1.2.2 Text marks

Besides row/column identifier strings, you can also pass positions as names of marks -- symbolic names for a position between two characters. Unlike absolute row/column positions, marks are virtual locations that move as new text is inserted or deleted (by your script or your user). A mark always refers to its original location, even if that location shifts to a different row and column over time.

To create a mark, call the text mark_set method with a string name and an index to give its logical location. For instance, this script sets the insert cursor at the start of the text initially, with a call like the first one here:

self.text.mark_set(INSERT, '1.0')             # set insert cursor to start
self.text.mark_set('linetwo', '2.0')          # mark current line 2

The name INSERT is a predefined special mark that identifies the insert cursor position; setting it changes the insert cursor's location. To make a mark of your own, simply provide a unique name as in the second call here, and use it anywhere you need to specify a text position. The mark_unset call deletes marks by name.

8.4.1.2.3 Text tags

In addition to absolute indexes and symbolic mark names, the Text widget supports the notion of tags -- symbolic names associated with one or more substrings within the Text widget's string. Tags can be used for many things, but they also serve to represent a position anywhere you need one: tagged items are named by their beginning and ending indexes, which can be later passed to position-based calls.

For example, Tkinter provides a built-in tag name SEL -- a Tkinter name preassigned to string "sel" -- which automatically refers to currently selected text. To fetch the text selected (highlighted) with a mouse, run either of these calls:

text = self.text.get(SEL_FIRST, SEL_LAST)      # use tags for from/to indexes
text = self.text.get('sel.first', 'sel.last')  # strings and constants work 

The names SEL_FIRST and SEL_LAST are just preassigned variables in the Tkinter module that refer to the strings used in the second line here. The text get method expects two indexes; to fetch text names by a tag, add .first and .last to the tag's name to get its start and end indexes.

To tag a substring, call the text widget's tag_add method with a tag name string and start and stop positions (text can also be tagged as added in insert calls). To remove a tag from all characters in a range of text, call tag_remove:

self.text.tag_add('alltext', '1.0', END)  # tag all text in the widget
self.text.tag_add(SEL, index1, index2)    # select from index1 up to index2
self.text.tag_remove(SEL, '1.0', END)     # remove selection from all text

The first line here creates a new tag that names all text in the widget -- from start through end positions. The second line adds a range of characters to the built-in SEL selection tag -- they are automatically highlighted, because this tag is predefined to configure its members that way. The third line removes all characters in the text string from the SEL tag (all selections are unselected). Note that the tag_remove call just untags text within the named range; to really delete a tag completely, call tag_delete instead.

You can map indexes to tags dynamically too. For example, the text search method returns the row.column index of the first occurrence of a string between start and stop positions. To automatically select the text thus found, simply add its index to the built-in SEL tag:

where = self.text.search(target, INSERT, END)  # search from insert cursor
pastit = where + ('+%dc' % len(target))        # index beyond string found
self.text.tag_add(SEL, where, pastit)          # tag and select found string
self.text.focus()                              # select text widget itself

If you only want one string to be selected, be sure to first run the tag_remove call listed earlier -- this code adds a selection in addition to any selections that already exist (it may generate multiple selections in the display). In general, you can add any number of substrings to a tag to process them as a group.

To summarize: indexes, marks, and tag locations can be used anytime you need a text position. For instance, the text see method scrolls the display to make a position visible; it accepts all three kinds of position specifiers:

self.text.see('1.0')          # scroll display to top
self.text.see(INSERT)         # scroll display to insert cursor mark
self.text.see(SEL_FIRST)      # scroll display to selection tag

Text tags can also be used in broader ways for formatting and event bindings, but I'll defer those details until the end of this section.

8.4.2 Adding Text-Editing Operations

Example 8-11 puts some of these concepts to work. It adds support for four common text-editing operations -- file save, text cut and paste, and string find searching -- by subclassing ScolledText to provide additional buttons and methods. The Text widget comes with a set of default keyboard bindings that perform some common editing operations too, but they roughly mimic the Unix Emacs editor, and are somewhat obscure; it's more common and user-friendly to provide GUI interfaces to editing operations in a GUI text editor.

Example 8-11. PP2E\Gui\Tour\simpleedit.py
#########################################################
# add common edit tools to scrolled text by inheritance;
# composition (embedding) would work just as well here;
# this is not robust! see PyEdit for a feature superset;
#########################################################

from Tkinter import * 
from tkSimpleDialog import askstring
from tkFileDialog   import asksaveasfilename
from quitter        import Quitter
from scrolledtext   import ScrolledText                   # here, not Python's

class SimpleEditor(ScrolledText):                         # see PyEdit for more
    def __init__(self, parent=None, file=None): 
        frm = Frame(parent)
        frm.pack(fill=X)
        Button(frm, text='Save',  command=self.onSave).pack(side=LEFT)
        Button(frm, text='Cut',   command=self.onCut).pack(side=LEFT)
        Button(frm, text='Paste', command=self.onPaste).pack(side=LEFT)
        Button(frm, text='Find',  command=self.onFind).pack(side=LEFT)
        Quitter(frm).pack(side=LEFT)
        ScrolledText.__init__(self, parent, file=file) 
        self.text.config(font=('courier', 9, 'normal'))
    def onSave(self):
        filename = asksaveasfilename()
        if filename:
            alltext = self.gettext()                      # first through last
            open(filename, 'w').write(alltext)            # store text in file
    def onCut(self):
        text = self.text.get(SEL_FIRST, SEL_LAST)         # error if no select
        self.text.delete(SEL_FIRST, SEL_LAST)             # should wrap in try
        self.clipboard_clear()              
        self.clipboard_append(text)
    def onPaste(self):                                     # add clipboard text
        try:
            text = self.selection_get(selection='CLIPBOARD')
            self.text.insert(INSERT, text)
        except TclError:
            pass                                           # not to be pasted
    def onFind(self):
        target = askstring('SimpleEditor', 'Search String?')
        if target:
            where = self.text.search(target, INSERT, END)  # from insert cursor
            if where:                                      # returns an index
                print where
                pastit = where + ('+%dc' % len(target))    # index past target
               #self.text.tag_remove(SEL, '1.0', END)      # remove selection
                self.text.tag_add(SEL, where, pastit)      # select found target
                self.text.mark_set(INSERT, pastit)         # set insert mark
                self.text.see(INSERT)                      # scroll display
                self.text.focus()                          # select text widget

if __name__ == '__main__':
    try:
        SimpleEditor(file=sys.argv[1]).mainloop()    # filename on command line
    except IndexError:
        SimpleEditor().mainloop()                    # or not

This, too, was written with one eye toward reuse -- the SimpleEditor class it defines could be attached or subclassed by other GUI code. As I'll explain at the end of this section, though, it's not yet as robust as a general-purpose library tool should be. Still, it implements a functional text editor in a small amount of portable code. When run standalone, it brings up the window in Figure 8-17 (shown running in Windows); index positions are printed on stdout after each successful find operation:

C:\...\PP2E\Gui\Tour>python simpleedit.py simpleedit.py
PP2E scrolledtext
14.4
24.4
Figure 8-17. simpleedit in action
figs/ppy2_0817.gif

The save operation pops up the common save dialog available in Tkinter, and tailored to look native on each platform. Figure 8-18 shows this dialog in action on Windows. Find operations also pop up a standard dialog box to input a search string (Figure 8-19); in a full-blown editor, you might want to save this string away to repeat the find again (we will, in the next chapter's PyEdit).

Figure 8-18. Save pop-up dialog on Windows
figs/ppy2_0818.gif
Figure 8-19. Find pop-up dialog
figs/ppy2_0819.gif
8.4.2.1 Using the clipboard

Besides text widget operations, Example 8-11 applies the Tkinter clipboard interfaces in its cut and paste functions. Together, these operations allow you to move text within a file (cut in one place, paste in another). The clipboard they use is just a place to store data temporarily -- deleted text is placed on the clipboard on a cut, and text is inserted from the clipboard on a paste. If we restrict our focus to this program alone, there really is no reason that the text string cut couldn't simply be stored in a Python instance variable. But the clipboard is actually a much larger concept.

The clipboard used by this script is an interface to a systemwide storage space, shared by all programs on your computer. Because of that, it can be used to transfer data between applications, even ones that know nothing of Tkinter. For instance, text cut or copied in a Microsoft Word session can be pasted in a SimpleEditor window, and text cut in SimpleEditor can be pasted in a Microsoft Notepad window (try it). By using the clipboard for cut and paste, SimpleEditor automatically integrates with the window system at large. Moreover, the clipboard is not just for the text widget -- it can also be used to cut and paste graphical objects in the Canvas widget (discussed next).

As used in this script, the basic Tkinter clipboard interface looks like this:

self.clipboard_clear()                            # clear the clipboard
self.clipboard_append(text)                       # store a text string on it
text = self.selection_get(selection='CLIPBOARD')  # fetch contents, if any

All of these calls are available as methods inherited by all Tkinter widget objects because they are global in nature. The CLIPBOARD selection used by this script is available on all platforms (a PRIMARY selection is also available, but is only generally useful on X Windows, so we'll ignore it here). Notice that the clipboard selection_get call throws a TclError exception if it fails; this script simply ignores it and abandons a paste request, but we'll do better later.

8.4.2.2 Composition versus inheritance

As coded, SimpleEditor uses inheritance to extend ScrolledText with extra buttons and callback methods. As we've seen, it's also reasonable to attach (embed) GUI objects coded as components, like ScrolledText. The attachment model is usually called composition; some people find it simpler to understand, and less prone to name clashes than extension by inheritance.

To give you an idea of the differences between these two approaches, the following sketches the sort of code you would write to attach a ScrolledText to SimpleEditor with changed lines in bold font (see file simpleedit-2.py on the CD for a complete composition implementation). It's mostly a matter of passing in the right parents, and adding an extra "st" attribute name to get to the Text widget's methods:

class SimpleEditor(Frame): 
    def __init__(self, parent=None, file=None):
        Frame.__init__(self, parent)
        self.pack()
        frm = Frame(self)
        frm.pack(fill=X)
        Button(frm, text='Save',  command=self.onSave).pack(side=LEFT)
                ...more...
        Quitter(frm).pack(side=LEFT)
        self.st = ScrolledText(self, file=file)            # attach, not subclass
        self.st.text.config(font=('courier', 9, 'normal')) 
    def onSave(self):
        filename = asksaveasfilename()
        if filename:
            alltext = self.st.gettext()                    # go through attribute
            open(filename, 'w').write(alltext)
    def onCut(self):
        text = self.st.text.get(SEL_FIRST, SEL_LAST) 
        self.st.text.delete(SEL_FIRST, SEL_LAST) 
                ...more...

The window looks identical when such code is run. I'll let you be the judge of whether composition or inheritance is better here. If you code your Python GUI classes right, they will work under either regime.

8.4.2.3 It's called "Simple" for a reason

Finally, before you change your system registry to make SimpleEditor your default text file viewer, I should mention that although it shows the basics, it's something of a stripped-down version of the PyEdit example we'll meet in Chapter 9. In fact, you should study that example now if you're looking for more complete Tkinter text processing code in general. Because the text widget is so powerful, it's difficult to demonstrate more of its features without the volume of code that is already listed in the PyEdit program.

I should also point out that SimpleEditor is not only limited in function, it's just plain careless -- many boundary cases go unchecked and trigger uncaught exceptions that don't kill the GUI, but are not handled or reported. Even errors that are caught are not reported to the user (e.g., a paste, with nothing to be pasted). Be sure to see the PyEdit example for a more robust and complete implementation of the operations introduced in SimpleEditor.

8.4.3 Advanced Text and Tag Operations

Besides position specifiers, text tags can also be used to apply formatting and behavior to both all characters in a substring, and all substrings added to a tag. In fact, this is where much of the power of the text widget lies:

  • Tags have formatting attributes for setting color, font, tabs, and line spacing and justification; to apply these to many parts of the text at once, associate them with a tag and apply formatting to the tag with the tag_config method, much like the general widget config we've been using.

  • Tags can also have associated event bindings, which lets you implement things like hyperlinks in a Text widget: clicking the text triggers its tag's event handler. Tag bindings are set with a tag_bind method, much like the general widget bind method we've already met.

With tags, it's possible to display multiple configurations within the same text widget; for instance, you can apply one font to the text widget at large, and other fonts to tagged text. In addition, the text widget allows you to embed other widgets at an index (they are treated like a single character), as well as images.

Example 8-12 illustrates the basics of all these advanced tools at once, and draws the interface captured in Figure 8-20. This script applies formatting and event bindings to three tagged substrings, displays text in two different font and color schemes, and embeds an image and a button. Double-clicking any of the tagged substrings (or the embedded button) with a mouse triggers an event that prints a "Got tag event" message to stdout.

Example 8-12. PP2E\Gui\Tour\texttags.py
# demo advanced tag and text interfaces

from Tkinter import *
root = Tk()
def hello(event): print 'Got tag event'

# make and config a Text
text = Text()
text.config(font=('courier', 15, 'normal'))                  # set font for all
text.config(width=20, height=12)
text.pack(expand=YES, fill=BOTH)
text.insert(END, 'This is\n\nthe meaning\n\nof life.\n\n')   # insert 6 lines

# embed windows and photos
btn = Button(text, text='Spam', command=lambda: hello(0))    # embed a button
btn.pack()
text.window_create(END, window=btn)                          # embed a photo
text.insert(END, '\n\n')
img = PhotoImage(file='../gifs/PythonPowered.gif')
text.image_create(END, image=img)

# apply tags to substrings
text.tag_add('demo', '1.5', '1.7')                       # tag 'is'
text.tag_add('demo', '3.0', '3.3')                       # tag 'the'
text.tag_add('demo', '5.3', '5.7')                       # tag 'life'
text.tag_config('demo', background='purple')             # change colors in tag
text.tag_config('demo', foreground='white')              # not called bg/fg here
text.tag_config('demo', font=('times', 16, 'underline')) # change font in tag
text.tag_bind('demo', '<Double-1>', hello)               # bind events in tag
root.mainloop()
Figure 8-20. Text tags in action
figs/ppy2_0820.gif

Such embedding and tag tools could ultimately be used to render a web page. In fact, Python's standard htmllib HTML parser module can help automate web page GUI construction. As you can probably tell, though, the text widget offers more GUI programming options than we have space to list here. For more details on tag and text options, consult other Tk and Tkinter references. Right now, art class is about to begin.

    I l@ve RuBoard Previous Section Next Section