This chapter concludes our look at building GUIs with Python and its standard tkinter library, by presenting a collection of realistic GUI programs. In the preceding four chapters, we met all the basics of tkinter programming. We toured the core set of widgets—Python classes that generate devices on a computer screen and respond to user events—and we studied a handful of advanced GUI programming techniques, including automation tools, redirection with sockets and pipes, and threading. Here, our focus is on putting those widgets and techniques together to create more useful GUIs. We’ll study:
A text editor program
A thumbnail photo viewer
An image slideshow
A painting program
A graphical clock
A simple tic-tac-toe game, just for fun[38]
As in Part II’s Chapter 6, I’ve pulled the examples in this chapter from my own library of Python programs that I really use. For instance, the text editor and clock GUIs that we’ll meet here are day-to-day workhorses on my machines. Because they are written in Python and tkinter, they work unchanged on my Windows and Linux machines, and they should work on Macs too.
Since these are pure Python scripts, their future evolution is entirely up to their users—once you get a handle on tkinter interfaces, changing or augmenting the behavior of such programs by editing their Python code is a snap. Although some of these examples are similar to commercially available programs (e.g., PyEdit is reminiscent of the Windows Notepad accessory), the portability and almost infinite configurability of Python scripts can be a decided advantage.
Later in the book, we’ll meet other tkinter GUI programs that put a good face on specific application domains. For instance, the following larger GUI examples show up in later chapters also:
A comprehensive email client (Chapter 14)
A (mostly external) persistent object table viewer (Chapter 17)
A (mostly external) tree data structure viewer (Chapter 18 and Chapter 19)
A customizable calculator widget (Chapter 19)
Smaller examples, including FTP and file-transfer GUIs, pop up in the Internet part as well. Most of these programs see regular action on my desktop, too. Because GUI libraries are general-purpose tools, there are very few domains that cannot benefit from an easy-to-use, easy-to-program, and widely portable user interface coded in Python and tkinter.
Beyond the examples in this book, you can also find higher-level GUI toolkits for Python, such as the Pmw, Tix, and ttk packages introduced in Chapter 7. Some such systems build upon tkinter to provide compound components such as notebook tabbed widgets, tree views, and balloon pop-up help.
In the next part of the book, we’ll also explore programs that build user interfaces in web browsers, instead of tkinter—a very different way of approaching the user interface experience. Although web browser interfaces have been historically limited in functionality and slowed by network latency, when combined with the rich Internet application (RIA) toolkits mentioned at the start of Chapter 7, browser-based GUIs today can sometimes approach the utility of traditional GUIs, albeit at substantial cost in software complexity and dependencies.
Especially for highly interactive and nontrivial interfaces, though, standalone/desktop tkinter GUIs can be an indispensable feature of almost any Python program you write. The programs in this chapter underscore just how far Python and tkinter can take you.
As for all case-study chapters in this text, this one is largely a learn-by-example exercise; most of the programs here are listed with minimal details. Along the way, I’ll highlight salient points and underscore new tkinter features that examples introduce, but I’ll also assume that you will study the listed source code and its comments for more information. Once we reach the level of complexity demonstrated by programs here, Python’s readability becomes a substantial advantage for programmers (and writers of books).
All of this book’s GUI examples are available in source code form in the book’s examples distribution described in the Preface. Because I’ve already shown the interfaces these scripts employ, this section consists mostly of screenshots, program listings, and a few brief words describing some of the most important aspects of these programs. In other words, this is a self-study section: read the source, run the examples on your own computer, and refer to the previous chapters for further details on the code listed here. Some of these programs may also be accompanied in the book examples distribution by alternative or experimental implementations not listed here; see the distribution for extra code examples.
Finally, I want to remind you that all of the larger programs listed in the previous sections can be run from the PyDemos and PyGadgets launcher bar GUIs that we met at the end of Chapter 10. Although I will try hard to capture some of their behavior in screenshots here, GUIs are event-driven systems by nature, and there is nothing quite like running one live to sample the flavor of its user interactions. Because of that, the launcher bars are really a supplement to the material in this chapter. They should run on most platforms and are designed to be easy to start (see the top-level README-PP4E.txt file for hints). You should go there and start clicking things immediately if you haven’t done so already.
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: according to its own Tools/Info option, PyEdit implements a full-featured, graphical text editor program in a total of 1,133 new lines of portable Python code, including whitespace and comments, divided between 1,088 lines in the main file and 45 lines of configuration module settings (at release, at least—final sizes may vary slightly in future revisions). Despite its relatively modest size, by systems programming standards, PyEdit is sufficiently powerful and robust to have served as the primary tool for coding most of the examples in this book.
PyEdit supports all the usual mouse and keyboard text-editing operations: cut and paste, search and replace, open and save, undo and redo, 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 it can be run in a variety of roles:
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 it also provides advanced functions such as running Python program code being edited, changing fonts and colors, “grep” threaded external file search, a multiple window interface, and so on. More important, because it is coded in Python, PyEdit is easy to customize, and it runs portably on Windows, X Windows, and Macintosh.
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 as a canned application. For example, Chapter 14’s PyMailGUI employs PyEdit in pop-up mode to view email attachments and raw text, and both PyMailGUI and the preceding chapter’s PyDemos display source code files this way.
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 PyMailGUI (in Chapter 14) 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 byproduct of coding GUIs with the class-based techniques we’ve seen in the last four chapters.
PyEdit sports lots of features, and the best way to learn how it works is to test-drive it for yourself—you can run it by starting the main file textEditor.py, by running files textEditorNoConsole.pyw or pyedit.pyw to suppress a console window on Windows, or from the PyDemos and PyGadgets launcher bars described at the end of Chapter 10 (the launchers themselves live in the top level of the book examples directory tree). To give you a sampling of PyEdit’s interfaces, Figure 11-1 shows the main window’s default appearance running in Windows 7, after opening PyEdit’s own source code file.
The main part of this window is a Text
widget object, and if you read Chapter 9’s coverage of this widget,
PyEdit text-editing operations will be familiar. It uses text marks,
tags, and indexes, and it implements cut-and-paste operations with
the system clipboard so that PyEdit can paste data to and from other
applications, even after an application of origin is closed. Both
vertical and horizontal scroll bars are cross-linked to the Text
widget, to support movement through
arbitrary files.
If PyEdit’s menu and toolbars look familiar, they should—PyEdit
builds the main window with minimal code and appropriate clipping
and expansion policies by mixing in the GuiMaker
class we coded in the prior
chapter (Example 10-3). 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. Menus can also be torn off at their dashed line to provide quick access to menu options in new top-level windows (handy for options without toolbar buttons).
PyEdit pops up a variety of modal and nonmodal dialogs, both standard and custom. Figure 11-2 shows the custom and nonmodal change, font, and grep dialogs, along with a standard dialog used to display file statistics (the final line count may vary, as I tend to tweak code and comments right up until final draft).
The main window in Figure 11-2 has been given new foreground and background colors (with the standard color selection dialog), and a new text font has been selected from either the font dialog or a canned list in the script that users can change to suit their preferences (this is Python, after all). Other toolbar and menu operations generally use popped-up standard dialogs, with a few new twists. For instance, 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 navigate 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 compiling and running code strings and for 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, using code like the following (see file simpleShell.py in the PyEdit’s directory if you wish to experiment with this), though you need a bit more to handle multiple-line statements and expression result displays:
# read and run Python statement strings: like PyEdit's run code menu option namespace = {} while True: try: line = input('>>> ') # single-line statements only except EOFError: break else: exec(line, 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 5 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 in PyEdit on your own to experiment.
When edited code is run in nonfile mode, you can view its printed
output in PyEdit’s console window. As we footnoted about eval
and exec
in Chapter 9, also make sure you trust
the source of code you run this way; it has all permissions that
the Python process does.
PyEdit not only pops up multiple special-purpose windows, it also allows multiple edit windows to be open concurrently, in either the same process or as independent programs. For illustration, Figure 11-3 shows three independently started instances of PyEdit, resized and running with a variety of color schemes and fonts. Since these are separate programs, closing any of these does not close the others. This figure also captures PyEdit torn-off menus at the bottom and the PyEdit help pop up on the right. The edit windows’ backgrounds are shades of green, red, and blue; use the Tools menu’s Pick options to set colors as you like.
Since these three PyEdit sessions are 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.
To run multiple edit windows in the same process, use the Tools menu’s Clone option to open a new empty window without erasing the content of another. Figure 11-4 shows the single-process scene with a main window, along with pop-ups related to the Search menu’s Grep option, described in the next section—a tool that walks directory trees in parallel threads, collecting files of matching names that contain a search string, and opening them on request. In Figure 11-4, Grep has produced an input dialog, a matches list, and a new PyEdit window positioned at a match after a double-click in the list box.
Another pop up appears while a Grep search is in progress, but the GUI remains fully active; in fact, you can launch new Greps while others are in progress. Notice how the Grep dialog also allows input of a Unicode encoding, used to decode file content in all text files visited during the tree search; I’ll describe how this works in the changes section ahead, but in most cases, you can accept the prefilled platform default encoding.
For more fun, use this dialog to run a Grep in directory
C:Python31
for all *.py
files that contain string %
—a quick look at how common the
original string formatting expression is, even in Python 3.1’s own
library code. Though not all %
are related to string formatting, most appear to be. Per a message
printed to standard output on Grep thread exit, the string
'%'
(which includes
substitution targets) occurs 6,050 times, and
the string ' % '
(with
surrounding spaces to better narrow in on operator appearances)
appears 3,741 times, including 130 in the
installed PIL extension—not exactly an obscure language tool! Here
are the messages printed to standard output during this search;
matches appear in a list box window:
...errors may vary per encoding type...
Unicode error in: C:Python31Liblib2to3 estsdatadifferent_encoding.py
Unicode error in: C:Python31Lib est est_doctest2.py
Unicode error in: C:Python31Lib est est_tokenize.py
Matches for % : 3741
PyEdit generates additional pop-up windows—including transient Goto and Find dialogs, color selection dialogs, dialogs that appear to collect arguments and modes for Run Code, and dialogs that prompt for entry of Unicode encoding names on file Open and Save if PyEdit is configured to ask (more on this ahead). In the interest of space, I’ll leave most other such behavior for you to witness live.
Prominently new in this edition, though, and subject to user configurations, PyEdit may ask for a file’s Unicode encoding name when opening a file, saving a new file begun from scratch, or running a Save As operation. For example, Figure 11-5 captures the scene after I’ve opened a file encoded in a Chinese character set scheme and pressed Open again to open a new file encoded in a Russian encoding. The encoding name input dialog shown in the figure appears immediately after the standard file selection dialog is dismissed, and it is prefilled with the default encoding choice configured (an explicit setting or the platform’s default). The displayed default can be accepted in most cases, unless you know the file’s encoding differs.
In general, PyEdit supports any Unicode character set that Python and tkinter do, for opens, display, and saves. The text in Figure 11-5, for instance, was encoding in a specific Chinese encoding in the file it came from (“gb2321” for file email-part--gb2312). An alternative UTF-8 encoding of this text is available in the same directory (file email-part--gb2312--utf8) which works per the default Windows encoding in PyEdit and Notepad, but the specific Chinese encoding file requires the explicitly entered encoding name to display properly in PyEdit (and won’t display correctly at all in Notepad).
After I enter the encoding name for the selected file (“koi8-r” for the file selected to open) in the input dialog of Figure 11-5, PyEdit decodes and pops up the text in its display. Figure 11-6 show the scene after this file has been opened and I’ve selected the Save As option in this window—immediately after a file selection dialog is dismissed, another encoding input dialog is presented for the new file, prefilled with the known encoding from the last Open or Save. As configured, Save reuses the known encoding automatically to write to the file again, but SaveAs always asks to allow for a new one, before trying defaults. Again, I’ll say more on the Unicode/Internationalization policies of PyEdit in the next section, when we discuss version 2.1 changes; in short, because user preferences can’t be predicted, a variety of policies may be selected by configuration.
Finally, when it’s time to shut down for the day, PyEdit does what it can to avoid losing changes not saved. When a Quit is requested for any edit window, PyEdit checks for changes and verifies the operation in a dialog if the window’s text has been modified and not saved. Because there may be multiple edit windows in the same process, when a Quit is requested in a main window, PyEdit also checks for changes in all other windows still open, and verifies exit if any have been altered—otherwise the Quit would close every window silently. Quits in pop-up edit windows destroy that window only, so no cross-process check is made. If no changes have been made, Quit requests in the GUI close windows and programs silently. Other operations verify changes in similar ways.
For other screenshots showing PyEdit in action, see the coverage of the following client programs:
PyDemos in Chapter 10 deploys PyEdit pop-ups to show source-code files.
PyView later in this chapter embeds PyEdit to display image note files.
PyMailGUI in Chapter 14 uses PyEdit to display email text, text attachments, and source.
The last of these especially makes heavy use of PyEdit’s functionality and includes screenshots showing PyEdit displaying additional Unicode text with Internationalized character sets. In this role, the text is either parsed from messages or loaded from temporary files, with encodings determined by mail headers.
I’ve updated this example in both the third and fourth editions of this book. Because this chapter is intended to reflect realistic programming practice, and because this example reflects that way that software evolves over time, this section and the one following it provide a quick rundown of some of the major changes made along the way to help you study the code.
Since the current version inherits all the enhancements of the one preceding it, let’s begin with the previous version’s additions. In the third edition, PyEdit was enhanced with:
A simple font specification dialog
Unlimited undo and redo of editing operations
File modified tests whenever content might be erased or changed
A user configurations module
Here are some quick notes about these extensions.
For the third edition of the book, PyEdit grew a font input dialog—a simple, three-entry, nonmodal dialog where you can type the font family, size, and style, instead of picking them from a list of preset options. Though functional, you can find more sophisticated tkinter font selection dialogs in both the public domain and within the implementation of Python’s standard IDLE development GUI (as mentioned earlier, it is itself a Python/tkinter program).
Also new in the third edition, PyEdit supports unlimited edit
undo and redo, as well as an accurate
modified check before quit, open, run, and
new actions to prompt for saves. It now verifies exits or
overwrites only if text has been changed, instead of always asking
naïvely. The underlying Tk 8.4 (or later) library provides an API,
which makes both these enhancements simple—Tk keeps undo and redo
stacks automatically. They are enabled with the Text
widget’s undo
configuration option and are
accessed with the widget methods edit_undo
and edit_redo
. Similarly, edit_reset
clears the stacks (e.g.,
after a new file open), and edit_modified
checks or sets the
automatic text modified flag.
It’s also possible to undo cuts and pastes right after you’ve done them (simply paste back from the clipboard or cut the pasted and selected text), but the new undo/redo operations are more complete and simpler to use. Undo was a suggested exercise in the second edition of this book, but it has been made almost trivial by the new Tk API.
For usability, the third edition’s version of PyEdit also allows users
to set startup configuration options by
assigning variables in a module, textConfig.py
. If present on the module
search path when PyEdit is imported or run, these assignments give
initial values for font, colors, text window size, and search case
sensitivity. Fonts and colors can be changed interactively in the
menus and windows can be freely resized, so this is largely just a
convenience. Also note that this module’s settings will be
inherited by all instances of PyEdit if it is importable in the
client program—even when it is a pop-up window or an embedded
component of another application. Client applications may define
their own version or configure this file on the module search path
per their needs.
Besides the updates described in the prior section, the following additional enhancements were made for this current fourth edition of this book:
PyEdit has been ported to run under Python 3.1, and its tkinter library.
The nonmodal change and font dialogs were fixed to work better if multiple instance windows are open: they now use per-dialog state.
A Quit request in main windows now verifies program exit if any other edit windows in the process have changed content, instead of exiting silently.
There’s a new Grep menu option and dialog for searching external files; searches are run in threads to avoid blocking the GUI and to allow multiple searches to overlap in time and support Unicode text.
There was a minor fix for initial positioning when text is inserted initially into a newly created editor, reflecting a change in underlying libraries.
The Run Code option for files now uses the base file name
instead of the full directory path after a chdir
to better support relative
paths; allows for command-line arguments to code run from files;
and inherits a patch made in Chapter 5’s launch
modes
which converts /
to in script paths. In addition, this
option always now runs an
update
between pop-up dialogs to
ensure proper display.
Perhaps most prominently, PyEdit now processes files in such a way as to support display and editing of text with arbitrary Unicode encodings, to the extent allowed by the underlying Tk GUI library for Unicode strings. Specifically, Unicode is taken into account when opening and saving files; when displaying text in the GUI; and when searching files in directories.
The following sections provide additional implementation notes on these changes.
The change dialog in the prior version saved its entry widgets on the text editor object, which meant that the most recent change dialog’s fields were used for every change dialog open. This could even lead to program aborts for finds in an older change dialog window if newer ones had been closed, since the closed window’s widgets had been destroyed—an unanticipated usage mode, which has been present since at least the second edition, and which I’d like to chalk up to operator error, but which was really a lesson in state retention! The same phenomenon existed in the font dialog—its most recently opened instance stole the show, though its brute force exception handler prevented program aborts (it issued error pop ups instead). To fix, the change and font dialogs now send per-dialog-window input fields as arguments to their callbacks. We could instead allow just one of each dialog to be open, but that’s less functional.
Though not quite as grievous, PyEdit also used to ignore changes in
other open edit windows on Quit in main windows. As a policy, on a
Quit in the GUI, pop-up edit windows destroy themselves only, but
main edit windows run a tkinter quit
to end the entire program. Although
all windows verify closes if their own content has changed, other
edit windows were ignored in the prior version—quitting a main
window could lose changes in other windows closed on program
exit.
To do better, this version keeps a list of all open managed
edit windows in the process; on Quit in main windows it checks
them all for changes, and verifies exit if any have changed. This
scheme isn’t foolproof (it doesn’t address quits run on widgets
outside PyEdit’s scope), but it is an improvement. A more ultimate
solution probably involves redefining or intercepting tkinter’s
own quit
method. To avoid
getting too detailed here, I’ll defer more on this topic until
later in this section (see the <Destroy>
event coverage ahead);
also see the relevant comments near the end of PyEdit’s source
file for implementation notes.
In addition, there is a new Grep option in the Search pull-down menu, which implements an external file search tool. This tool scans an entire directory tree for files whose names match a pattern, and which contain a given search string. Names of matches are popped up in a new nonmodal scrolled list window, with lines that identify all matches by filename, line number, and line content. Clicking on a list item opens the matched file in a new nonmodal and in-process PyEdit pop-up edit window and automatically moves to and selects the line of the match. This achieves its goal by reusing much code we wrote earlier:
The find
utility we
wrote in Chapter 6 to do its
tree walking
The scrolled list utility we coded in Chapter 9 for displaying matches
The form row builder we wrote in Chapter 10 for the nonmodal input dialog
The existing PyEdit pop-up window mode logic to display matched files on request
The existing PyEdit go-to callback and logic to move to the matched line in a file
To avoid blocking the GUI while files are searched during tree walks, Grep
runs searches in parallel threads. This
also allows multiple greps to be running at once and to overlap
in time arbitrarily (especially useful if you grep in larger
trees, such as Python’s own library or full source trees). The
standard threads, queues, and after
timer loops technique we learned
in Chapter 10 is applied
here—non-GUI producer threads find matches and place them on a
queue to be detected by a timer loop in the main GUI
thread.
As coded, a timer loop is run only when a grep is in
progress, and each grep uses its own thread, timer loop, and
queue. There may be multiple threads and loops running, and
there may be other unrelated threads, queues, and timer loops in
the process. For instance, an attached PyEdit component in Chapter 14’s PyMailGUI program can run
grep threads and loops of its own, while PyMailGUI runs its own
email-related threads and queue checker. Each loop’s handler is
dispatched independently from the tkinter event stream
processor. Because of the simpler structure here, the general
threadtools
callback queue of
Chapter 10 is not used here. For
more notes on grep thread implementation see the source code
ahead, and compare to file _unthreaded-textEditor.py in the examples
package, a nonthreaded version of PyEdit.
If you study the Grep option’s code, you’ll notice that it also allows input of a tree-wide Unicode encoding, and catches and skips any Unicode decoding error exceptions generated both when processing file content and walking the tree’s filenames. As we learned in Chapters 4 and 6, files opened in text mode in Python 3.X must be decodable per a provided or platform default Unicode encoding. This is particular problematic for Grep, as directory trees may contain files of arbitrarily mixed encoding types.
In fact, it’s common on Windows to have files with content in ASCII, UTF-8, and UTF-16 form mixed in the same tree (Notepad’s “ANSI,” “Utf-8,” and “Unicode”), and even others in trees that contain content obtained from the Web or email. Opening all these with UTF-8 would trigger exceptions in Python 3.X, and opening all these in binary mode yields encoded text that will likely fail to match a search key string. Technically, to compare at all, we’d still have to decode the bytes read to text or encode the search key string to bytes, and the two would only match if the encodings used both succeed and agree.
To allow for mixed encoding trees, the Grep dialog opens
in text mode and allows an encoding name to be input and used to
decode file content for all files in the tree searched. This
encoding name is prefilled with the platform content default for
convenience, as this will often suffice. To search trees of
mixed file types, users may run multiple Greps with different
encoding names. The names of files searched might fail to decode
as well, but this is largely ignored in the current release:
they are assumed to satisfy the platform filename convention,
and end the search if they don’t (see Chapters 4 and 6 for more on filename encoding
issues in Python itself, as well as the find
walker reused here).
In addition, Grep must take care to catch and recover from
encoding errors, since some files with matching names that it
searches might still not be decodable per the input encoding,
and in fact might not be text files at all. For example,
searches in Python 3.1’s
standard library (like the example Grep for %
described earlier) run into a
handful of files which do not decode properly on my Windows
machine and would otherwise crash PyEdit. Binary files which
happen to match the filename patterns would fare even
worse.
In general, programs can avoid Unicode encoding errors by either catching exceptions or opening files in binary mode; since Grep might not be able to interpret some of the files it visits as text at all, it takes the former approach. Really, opening even text files in binary mode to read raw byte strings in 3.X mimics the behavior of text files in 2.X, and underscores why forcing programs to deal with Unicode is sometimes a good thing—binary mode avoids decoding exceptions, but probably shouldn’t, because the still-encoded text might not work as expected. In this case, it might yield invalid comparison results.
For more details on Grep’s Unicode support, and a set of
open issues and options surrounding it, see the source code
listed ahead. For a suggested enhancement, see also the re
pattern matching module in Chapter 19—a tool we could use to search for
patterns instead of specific strings.
Also in this version, text editor updates itself before inserting text
into its text widget at construction time when it is passed an
initial file name in its loadFirst
argument. Sometime after the
third edition and Python 2.5, either Tk or tkinter changed such
that inserting text before an update call caused the scroll
position to be off by one—the text editor started with line 2 at
its top in this mode instead of line 1. This also occurs in the
third edition’s version of this example under Python 2.6, but not
2.5; adding an update
correctly
positions at line 1 initially. Obscure but true in the real world of library dependencies![39]
Clients of the classes here should also update
before manually inserting text
into a newly created (or packed) text editor object for accurate
positioning; PyView later in this chapter as well as PyMailGUI in
Chapter 14 now do. PyEdit doesn’t
update itself on every construction, because it may be created
early by, or even hidden in, an enclosing GUI (for instance, this
would show a half-complete window in PyView). Moreover, PyEdit
could automatically update
itself at the start of setAllText
instead of requiring this
step of clients, but forced update
is required only once initially
after being packed (not before each text insertion), and this too
might be an undesirable side effect in some conceivable use cases.
As a rule of thumb, adding unrelated operations to methods this
way tends to limit their scope.
The Run Code option in the Tools menu was fixed in three ways that make it more useful for running code being edited from its external file, rather than in-process:
After changing to the file’s directory in order to make
any relative filenames in its code accurate, PyEdit now strips
off any directory path prefix in the file’s name before
launching it, because its original directory path may no
longer be valid if it is relative instead
of absolute. For instance, paths of files opened manually are
absolute, but file paths in PyDemos’s Code pop ups are all
relative to the example package root and would fail after a
chdir
.
PyEdit now correctly uses launcher tools that support command-line arguments for file mode on Windows.
PyEdit inherits a fix in the underlying launchmodes
module that changes
forward slashes in script path names to backslashes (though
this was later made a moot point by stripping relative path
prefixes). PyEdit gets by with forward slashes on Windows
because open
allows them,
but some Windows launch tools do not.
Additionally, for both code run from files and code run in
memory, this version adds an update
call between pop-up dialogs to
ensure that later dialogs appear in all cases (the second
occasionally failed to pop up in rare contexts). Even with these
fixes, Run Code is useful but still not fully robust. For example,
if the edited code is not run from a file, it is run in-process
and not spawned off in a thread, and so may block the GUI. It’s
also not clear how best to handle import paths and directories for
files run in nonfile mode, or whether this mode is worth retaining
in general. Improve as desired.
Finally, because Python 3.X now fully supports Unicode text, this version of PyEdit does, too—it allows text of arbitrary Unicode encodings and character sets to be opened and saved in files, viewed and edited in its GUI, and searched by its directory search utility. This support is reflected in PyMailGUI’s user interface in a variety of ways:
Opens must ask the user for an encoding (suggesting the platform default) if one is not provided by the client application or configuration
Saves of new files must ask for an encoding if one is not provided by configuration
Display and edit must rely on the GUI toolkit’s own support for Unicode text
Grep directory searches must allow for input of an encoding to apply to all files in the tree and skip files that fail to decode, as described earlier
The net result is to support Internationalized text which may differ from the platform’s default encoding. This is particularly useful for text files fetched over the Internet by email or FTP. Chapter 14’s PyMailGUI, for example, uses an embedded PyEdit object to view text attachments of arbitrary origin and encoding. The Grep utility’s Unicode support was described earlier; the remainder of this model essentially reduces to file opens and saves, as the next section describes.
Because strings are always Unicode code-point strings once they are created in memory, Unicode support really means supporting arbitrary encodings for text files when they are read and written. Recall that text can be stored in files in a variety of Unicode encoding format schemes; strings are decoded from these formats when read and encoded to them when written. Unless text is always stored in files using the platform’s default encoding, we need to know which encoding to use, both to load and to save.
To make this work, PyEdit uses the approaches described in
detail in Chapter 9, which
we won’t repeat in full here. In brief, though, tkinter’s
Text
widget accepts content
as either str
and bytes
and always returns it as
str
. PyEdit maps this
interface to and from Python file objects as follows:
Decoding from file bytes to strings in general requires the name of an encoding type that is compatible with data in the file, and fails if the two do not agree (e.g., decoding 8-bit data to ASCII). In some cases, the Unicode type of the text file to be opened may be unknown.
To load, PyEdit first tries to open input files in
text mode to read str
strings, using an encoding obtained from a variety of
sources—a method argument for a known type (e.g., from
headers of email attachments or source files opened by
demos), a user dialog reply, a configuration module
setting, and the platform default. Whenever prompting
users for an open encoding, the dialog is prefilled with
the first choice implied by the configuration file, as a
default and suggestion.
If all these encoding sources fail to decode, the
file is opened in binary mode to read text as bytes
without an encoding name,
effectively delegating encoding issues to the Tk GUI
library; in this case, any
end-lines are manually
converted to
on
Windows so they correctly display and save later. Binary
mode is used only as a last resort, to avoid relying on
Tk’s policies and limited character set support for raw
bytes.
The tkinter Text
widget returns its content on request as str
strings, regardless of
whether str
or bytes
were inserted. Because of
that, all text processing of content fetched from the GUI
is conducted in terms of str
Unicode strings here.
Encoding from strings to file bytes is generally more flexible than decoding and need not use the same encoding from which the string was decoded, but can also fail if the chosen scheme is too narrow to handle the string’s content (e.g., encoding 8-bit text to ASCII).
To save, PyEdit opens output files in text mode to
perform end-line mappings and Unicode encoding of str
content. An encoding name is
again fetched from one of a variety of sources—the same
encoding used when the file was first opened or saved (if
any), a user dialog reply, a configuration module setting,
and the platform default. Unlike opens, save dialogs that
prompt for encodings are prefilled with the known encoding
if there is one as a suggestion; otherwise, the dialog is
prefilled with the next configured choice as a default, as
for opens.
The user input dialog on opens and saves is the only GUI
implication of these policies; other options are selected in
configuration module assignments. Since it’s impossible to
predict all possible use case scenarios, PyEdit takes a liberal
approach: it supports all conceivable modes, and allows the way
it obtains file encodings to be heavily tailored by users in the
package’s own textConfig
module. It attempts one encoding name source after another, if
enabled in textConfig
, until
it finds an encoding that works. This aims to provide maximum
flexibility in the face of an uncertain Unicode world.
For example, subject to settings in the configuration file, saves reuse the encoding used for the file when it was opened or initially saved, if known. Both new files begun from scratch (with New or manual text inserts) and files opened in binary mode as a last resort have no known encoding until saved, but files previously opened as text do. Also subject to configuration file settings, we may prompt users for an encoding on Save As (and possibly Save) because they may have a preference for new files they create. We also may prompt when opening an existing file, because this requires its current encoding; although the user may not always know what this is (e.g., files fetched over the Internet), the user may wish to provide it in others. Rather than choosing a course of action in such cases, we rely on user configuration.
All of this is really relevant only to PyEdit clients that
request an initial file load or allow files to be opened and
saved in the GUI. Because content can be inserted as str
or bytes
, clients can always open and
read input files themselves prior to creating a text editor
object and insert the text manually for viewing. Moreover,
clients can fetch content manually and save in any fashion
preferred. Such a manual approach might prove useful if PyEdit’s
polices are undesirable for a given context. Since the Text
widget always returns content as
a str
, the rest of this
program is unaffected by the data type of text inserted.
Keep in mind that these policies are still subject to the
Unicode support and constraints of the underlying Tk GUI
toolkit, as well as Python’s tkinter interface to it. Although
PyEdit allows text to be loaded and saved in arbitrary Unicode
encodings, it cannot guarantee that the GUI library will display
such text as you wish. That is, even if we get the Unicode story
right on the Python side of the fence, we’re still at the mercy
of other software layers which are beyond the scope of this
book. Tk seems to be robust across a wide range of character
sets if we pass it already decoded Python str
Unicode strings (see the
Internationalization support in Chapter 14’s PyMailGUI for samples), but
your mileage might vary.
Also keep in mind that the Unicode policies adopted in PyEdit reflect the use cases of its sole current user, and have not been broadly tested for ergonomics and generality; as a book example, this doesn’t enjoy the built-in test environment of open source projects. Other schemes and source orderings might work well, too, and it’s impossible to guess the preferences of every user in every context. For instance:
It’s not clear if user prompts should be attempted before configuration settings, or vice-versa.
Perhaps we also should always ask the user for an encoding as a last resort, irrespective of configuration settings.
For saves, we could also try to guess an encoding to
apply to the str
content
(e.g., try UTF-8, Latin-1, and other common types), but our
guess may not be what the user has in mind.
It’s likely that users will wish to save a file in the same encoding with which it was first opened, or initially saved if started from scratch. PyEdit provides support to do so, or else the GUI might ask for a given file’s encoding more than once. However, because some users might also want to use Save again to overwrite the same file with a different encoding, this can be disabled in the configuration module. The latter role might sound like a Save As, but the next bullet explains why it may not.
Similarly, it’s not obvious if Save As should also reuse the encoding used when the file was first opened or initially saved or ask for a new one—is this a new file entirely, or a copy of the prior text with its known encoding under a new name? Because of such ambiguities, we allow the known-encoding memory feature to be disabled for Save As, or for both Save and Save As in the configuration module. As shipped, it is enabled for Save only, not Save As. In all cases, save encoding prompt dialogs are prefilled with a known encoding name as a default.
The ordering of choice seems debatable in general. For instance, perhaps Save As should fall back on the known encoding if not asking the user; as is, if configured to not ask and not use a known encoding, this operation will fall back on saving per an encoding in the configuration file or the platform default (e.g., UTF-8), which may be less than ideal for email parts of known encodings.
And so on. Because such user interface choices require wider use to resolve well, the general and partly heuristic policy here is to support every option for illustration purposes in this book, and rely on user configuration settings to resolve choices. In practice, though, such wide flexibility may turn out to be overkill; most users probably just require one of the policies supported here.
It may also prove better to allow Unicode policies to be selected in the GUI itself, instead of coded in a configuration module. For instance, perhaps every Open, Save, and Save As should allow a Unicode encoding selection, which defaults to the last known encoding, if any. Implementing this as a pull-down encoding list or entry field in the Save and Open dialogs would avoid an extra pop up and achieve much the same flexibility.
In PyEdit’s current implementation, enabling user prompts in the configuration file for both opens and saves will have much the same effect, and at least based upon use cases I’ve encountered to date, that is probably the best policy to adopt for most contexts.
Hence, as shipped:
Open uses a passed-in encoding, if any, or else prompts for an encoding name first
Save reuses a known encoding if it has one, and otherwise prompts for new file saves
Save As always prompts for an encoding name first for the new file
Grep allows an encoding to be input in its dialog to apply to the full tree searched
On the other hand, because the platform default will
probably work silently without extra GUI complexity for the vast
majority of users anyhow, the textConfig
setting can prevent the pop
ups altogether and fall back on an explicit encoding or platform
default. Ultimately, structuring encoding selection well
requires the sort of broad user experience and feedback which is
outside this book’s scope, not the guesses of a single
developer. As always, feel free to tailor as you like.
See the test
subdirectory in the examples for a few Unicode text files to
experiment with opening and saving, in conjunction with textConfig
changes. As suggested when
we saw Figures 11-5 and 11-6, this directory contains
files that use International character sets, saved in different
encodings. For instance, file email-part--koi8-r there is formatted
per the Russian encoding koi8-r, and email-part--koi8-r--utf8 is the same
file saved in UTF-8 encoding format; the latter works well in
Notepad on Windows, but the former will only display properly
when giving an explicit encoding name to PyEdit.
Better yet, make a few Unicode files yourself, by changing
textConfig
to hardcode
encodings or always ask for encodings—thanks largely to Python
3.X’s Unicode support, PyEdit allows you to save and load in
whatever encoding you wish.
Before we get to the code, one of version 2.1’s changes merits a few
additional words, because it illustrates the fundamentals of
tkinter window closure in a realistic context. We learned in Chapter 8 that tkinter also has a
<Destroy>
event for the
bind
method which is run when
windows and widgets are destroyed. Although we could bind this
event on PyEdit windows or their text widgets to catch destroys on
program exit, this won’t quite help with the use case here.
Scripts cannot generally do anything GUI-related in this event’s
callback, because the GUI is being torn down. In particular, both
testing a text widget for modifications and fetching its content
in a <Destroy>
handler
can fail with an exception. Popping up a save verification dialog
at this point may act oddly, too: it only shows up after some of
the window’s widgets may have already been erased (including the
text widget whose contents the user may wish to inspect and
save!), and it might sometimes refuse to go away
altogether.
As also mentioned in Chapter 8, running a quit
method call does not trigger any
<Destroy>
events, but
does trigger a fatal Python error message on exit. To use destroy
events at all, PyEdit would have to be redesigned to close windows
on Quit requests with the destroy
method only, and rely on the
Tk
root window destruction
protocol for exits; immediate shutdowns would be unsupported, or
require tools such as sys.exit
.
Since <Destroy>
doesn’t
allow GUI operations anyhow, this change is unwarranted. Code
after mainloop
won’t help here
either, because mainloop
is
called outside PyEdit’s code, and this is far too late to detect
text changes and save in any event (pun nearly accidental).
In other words, <Destroy>
won’t help—it doesn’t
support the goal of verifying saves on window closes, and it
doesn’t address the issue of quit
and destroy
calls run for widgets outside
the scope of PyEdit window classes. Because of such complications,
PyEdit instead relies on checking for changes in each individual
window before closed, and for changes in its cross-process window
list before quits in any of its main windows. Applications that
follow its expected window model check for changes automatically.
Applications that embed a PyEdit as a component of a larger GUI,
or use it in other ways that are outside PyEdit’s control, are
responsible for testing for edit changes on closes if they should
be saved, before the PyEdit object or its widgets are
destroyed.
To experiment with the <Destroy>
event’s behavior
yourself, see file destroyer.py in the book examples
package; it simulates what PyEdit would need to do on <Destroy>
. Here is the crucial
subset of its code, with comments that explain behavior:
def onDeleteRequest(): print('Got wm delete') # on window X: can cancel destroy root.destroy() # triggers <Destroy> def doRootDestroy(event): print('Got event <destroy>') # called for each widget in root if event.widget == text: print('for text') print(text.edit_modified()) # <= Tcl error: invalid widget ans = askyesno('Save stuff?', 'Save?') # <= may behave badly if ans: print(text.get('1.0', END+'-1c')) # <= Tcl error: invalid widget root = Tk() text = Text(root, undo=1, autoseparators=1) text.pack() root.bind('<Destroy>', doRootDestroy) # for root and children root.protocol('WM_DELETE_WINDOW', onDeleteRequest) # on window X button Button(root, text='Destroy', command=root.destroy).pack() # triggers <Destroy> Button(root, text='Quit', command=root.quit).pack() # <= fatal Python error, mainloop() # no <Destroy> on quit()
See the code listings in the next section for more on all of the above. Also be sure to see the mail file’s documentation string for a list of suggested enhancements and open issues (noted under “TBD”). PyEdit is largely designed to work according to my preferences, but it’s open to customization for yours.
The PyEdit program consists of only a small configuration module and one
main source file, which is just over 1,000 lines long—a
.py that can be either run or imported. For use
on Windows, there is also a one-line .pyw file
that just executes the .py file’s contents with
an execfile('textEditor.py')
call. The .pyw suffix avoids the DOS console
streams window pop up when launched by clicking on Windows.
Today, .pyw files can be both imported
and run, like normal .py files (they can also
be double-clicked, and launched by Python tools such as os.system
and os.startfile
), so we don’t really need a
separate file to support both import and console-less run modes. I
retained the .py, though, in order to see
printed text during development and to use PyEdit as a simple
IDE—when the run code option is selected, in nonfile mode, printed
output from code being edited shows up in PyEdit’s DOS console
window in Windows. Clients will normally import the
.py file.
On to the code. First, PyEdit’s user configuration module is listed in Example 11-1. This is mostly a convenience, for providing an initial look-and-feel other than the default. PyEdit is coded to work even if this module is missing or contains syntax errors. This file is primarily intended for when PyEdit is the top-level script run (in which case the file is imported from the current directory), but you can also define your own version of this file elsewhere on your module import search path to customize PyEdit.
See textEditor.py ahead for more on how this module’s settings are loaded. Its contents are loaded by two different imports—one import for cosmetic settings assumes this module itself (not its package) is on the module search path and skips it if not found, and the other import for Unicode settings always locates this file regardless of launch modes. Here’s what this division of configuration labor means for clients:
Because the first import for cosmetic settings is relative to the module search path, not to the main file’s package, a new textConfig.py can be defined in each client application’s home directory to customize PyEdit windows per client.
Conversely, Unicode settings here are always loaded from
this file using package relative imports if needed, because
they are more critical and unlikely to vary. The package
relative import used for this is equivalent to a full package
import from the PP4E
root,
but not dependent upon directory structure.
Like much of the heuristic Unicode interface described earlier, this import model is somewhat preliminary, and may require revision if actual usage patterns warrant.
""" 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; #---------------------------------------------------------------------------------- # 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
Next, Example 11-2 gives the .pyw launching file used to suppress a DOS pop up on Windows when run in some modes (for instance, when double-clicked), but still allow for a console when the .py file is run directly (to see the output of edited code run in nonfile mode, for example). Clicking this directly is similar to the behavior when PyEdit is run from the PyDemos or PyGadgets demo launcher bars.
""" run without a DOS pop up on Windows; could use just a .pyw for both imports and launch, but .py file retained for seeing any printed text """ exec(open('textEditor.py').read()) # as if pasted here (or textEditor.main())
Example 11-2 serves its purpose, but later in this book update project, I grew tired of using Notepad to view text files from command lines run in arbitrary places and wrote the script in Example 11-3 to launch PyEdit in a more general and automated fashion. This script disables the DOS pop up, like Example 11-2, when clicked or run via a desktop shortcut on Windows, but also takes care to configure the module search path on machines where I haven’t used Control Panel to do so, and allows for other launching scenarios where the current working directory may not be the same as the script’s directory.
#!/usr/bin/python """ convenience script to launch pyedit from arbitrary places with the import path set as required; sys.path for imports and open() must be relative to the known top-level script's dir, not cwd -- cwd is script's dir if run by shortcut or icon click, but may be anything if run from command-line typed into a shell console window: use argv path; this is a .pyw to suppress console pop-up on Windows; add this script's dir to your system PATH to run from command-lines; works on Unix too: / and handled portably; """ import sys, os mydir = os.path.dirname(sys.argv[0]) # use my dir for open, path sys.path.insert(1, os.sep.join([mydir] + ['..']*3)) # imports: PP4E root, 3 up exec(open(os.path.join(mydir, 'textEditor.py')).read())
To run this from a command line in a console window, it simply has to be on your system path—the action taken by the first line in the following could be performed just once in Control Panel on Windows:
C:...PP4EInternetWeb>set PATH=%PATH%;C:...PP4EGuiTextEditor
C:...PP4EInternetWeb>pyedit.pyw test-cookies.py
This script works on Unix, too, and is unnecessary if you set your PYTHONPATH and PATH system variables (you could then just run textEditor.py directly), but I don’t do so on all the machines I use. For more fun, try registering this script to open “.txt” files automatically on your computer when their icons are clicked or their names are typed alone on a command line (if you can bear to part with Notepad, that is).
And finally, the module in Example 11-4 is PyEdit’s implementation. This file may run directly as a top-level script, or it can be imported from other applications. Its code is organized by the GUI’s main menu options. 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.
""" ################################################################################ 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 PP4E.Gui.Tools.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 # 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 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' ', b' ') # 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 dropped if present on open by text mode (auto) and binary mode (manually); if manual content inserts, must delete 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 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+'-%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 ############################################################################ # 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 + '+%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): """ 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(' ')) # 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: ' + 'line: %s column: %s ' % where + 'File text statistics: ' + 'chars: %d lines: %d words: %d ' % (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() + ' ', 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 PP4E.Gui.ShellGui.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) 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) 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 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 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
In Chapter 9, we wrote a simple thumbnail image viewer that scrolled its thumbnails in a canvas. That program in turn built on techniques and code we developed at the end of Chapter 8 to handle images. In both places, I promised that we’d eventually meet a more full-featured extension of the ideas we deployed.
In this section, we finally wrap up the thumbnail images thread by studying PyPhoto—an enhanced image viewing and resizing program. PyPhoto’s basic operation is straightforward: given a directory of image files, PyPhoto displays their thumbnails in a scrollable canvas. When a thumbnail is selected, the corresponding image is displayed full size in a pop-up window.
Unlike our prior viewers, though, PyPhoto is clever enough to scroll (rather than crop) images too large for the physical display. Moreover, PyPhoto introduces the notion of image resizing—it supports mouse and keyboard events that resize the image to one of the display’s dimensions and zoom the image in and out. Once images are opened, the resizing logic allows images to be grown or shrunk arbitrarily, which is especially handy for images produced by a digital camera that may be too large to view all at once.
As added touches, PyPhoto also allows the image to be saved in a file (possibly after being resized), and it allows image directories to be selected and opened in the GUI itself, instead of just as command-line arguments.
Put together, PyPhoto’s features make it an image-processing program, albeit one with a currently small set of processing tools. I encourage you to experiment with adding new features of your own; once you get the hang of the Python Imaging Library (PIL) API, the object-oriented nature of PyPhoto makes adding new tools remarkably simple.
In order to run PyPhoto, you’ll need to fetch and install the PIL extension package described in Chapter 8. PyPhoto inherits much of its functionality from PIL—PIL is used to support extra image types beyond those handled by standard tkinter (e.g., JPEG images) and to perform image-processing operations such as resizes, thumbnail creation, and saves. PIL is open source like Python, but it is not presently part of the Python standard library. Search the Web for PIL’s location (http://www.pythonware.com is currently a safe bet). Also check the Extensions directory of the examples distribution package for a PIL self-installer.
The best way to get a feel for PyPhoto is to run it live on your own machine to see how images are scrolled and resized. Here, we’ll present a few screenshots to give the general flavor of the interaction. You can start PyPhoto by clicking its icon, or you can start it from the command line. When run directly, it opens the images subdirectory in its source directory, which contains a handful of photos. When you run it from the command line, you can pass in an initial image directory name as a command-line argument. Figure 11-7 captures the main thumbnail window when run directly.
Internally, PyPhoto is loading or creating thumbnail images before this window appears, using tools coded in Chapter 8. Startup may take a few seconds the first time you open a directory, but it is quick thereafter—PyPhoto caches thumbnails in a local subdirectory so that it can skip the generation step the next time the directory is opened.
Technically, there are three different ways PyPhoto may start
up: viewing an explicit directory listed on the command line;
viewing the default images directory when no
command-line argument is given and when images
is present where the program is run; or displaying a simple
one-button window that allows you to select directories to open on
demand, when no initial directory is given or present (see the
code’s __main__
logic).
PyPhoto also lets you open additional folders in new thumbnail windows, by pressing the D key on your keyboard in either a thumbnail or an image window. Figure 11-8, for instance, captures the pop-up window produced in Windows 7 to select a new image folder, and Figure 11-9 shows the result when I select a directory copied from one of my digital camera cards—this is a second PyPhoto thumbnail window on the display. Figure 11-8 is also opened by the one-button window if no initial directory is available.
When a thumbnail is selected, the image is displayed in a
canvas, in a new pop-up window. If it’s too large for the display,
you can scroll through its full size with the window’s scroll bars.
Figure 11-10 captures one image
after its thumbnail is clicked, and Figure 11-11 shows the Save
As dialog issued when the S key is pressed in the image window; be
sure to type the desired filename extension (e.g., .jpg
) in this Save As dialog, because PIL
uses it to know how to save the image to the file. In general, any
number of PyPhoto thumbnail and image windows can be open at once,
and each image can be saved independently.
Beyond the screenshots already shown, this system’s interaction is difficult to capture in a static medium such as this book—you’re better off test-driving the program live.
For example, clicking the left and right mouse buttons will resize the image to the display’s height and width dimensions, respectively, and pressing the I and O keys will zoom the image in and out in 10 percent increments. Both resizing schemes allow you to shrink an image too large to see all at once, as well as expand small photos. They also preserve the original aspect ratio of the photo, by changing its height and width proportionally, while blindly resizing to the display’s dimensions would not (height or width may be stretched).
Once resized, images may be saved in files at their current size. PyPhoto is also smart enough to make windows full size on Windows, if an image is larger than the display.
Because PyPhoto simply extends and reuses techniques and code we met earlier in the book, we’ll omit a detailed discussion of its code here. For background, see the discussion of image processing and PIL in Chapter 8 and the coverage of the canvas widget in Chapter 9.
In short, PyPhoto uses canvases in two ways: for thumbnail
collections and for opened images. For thumbnails, the same sort of
canvas layout code as the earlier thumbnails viewer in Example 9-15 is employed. For
images, a canvas is used as well, but the canvas’s scrollable (full)
size is the image size, and the viewable area size is the minimum of
the physical screen size or the size of the image itself. The
physical screen size is available from the maxsize()
method of Toplevel
windows. The net effect is that
selected images may be scrolled now, too, which comes in handy if
they are too big for your display (a common case for pictures
snapped with newer digital cameras).
In addition, PyPhoto binds keyboard and mouse events to
implement resizing and zoom operations. With PIL, this is simple—we
save the original PIL image, run its resize
method with the new image size, and
redraw the image in the canvas. PyPhoto also makes use of file open
and save dialog objects, to remember the last directory
visited.
PIL supports additional operations, which we could add as new events, but resizing is sufficient for a viewer. PyPhoto does not currently use threads, to avoid becoming blocked for long-running tasks (opening a large directory the first time, for instance). Such enhancements are left as suggested exercises.
PyPhoto is implemented as the single file of Example 11-5, though it gets
some utility for free by reusing the thumbnail generation function
of the viewer_thumbs
module that
we originally wrote near the end of Chapter 8 in Example 8-45. To spare you
from having to flip back and forth too much, here’s a copy of the
code of the thumbs function imported and used here:
# imported from Chapter 8...
def makeThumbs(imgdir, size=(100, 100), subdir='thumbs'):
# returns a list of (image filename, thumb image object);
thumbdir = os.path.join(imgdir, subdir)
if not os.path.exists(thumbdir):
os.mkdir(thumbdir)
thumbs = []
for imgfile in os.listdir(imgdir):
thumbpath = os.path.join(thumbdir, imgfile)
if os.path.exists(thumbpath):
thumbobj = Image.open(thumbpath) # use already created
thumbs.append((imgfile, thumbobj))
else:
print('making', thumbpath)
imgpath = os.path.join(imgdir, imgfile)
try:
imgobj = Image.open(imgpath) # make new thumb
imgobj.thumbnail(size, Image.ANTIALIAS) # best downsize filter
imgobj.save(thumbpath) # type via ext or passed
thumbs.append((imgfile, imgobj))
except: # not always IOError
print("Skipping: ", imgpath)
return thumbs
Some of this example’s thumbnail selection window code is also very similar to our earlier limited scrolled-thumbnails example in Chapter 9, but it is repeated in this file instead of imported, to allow for future evolution (Chapter 9’s functional subset is now officially demoted to prototype).
As you study this file, pay particular attention to the way it
factors code into reused functions and methods,
to avoid redundancy; if we ever need to change the way zooming
works, for example, we have just one method to change, not two. Also
notice its ScrolledCanvas
class—a
reusable component that handles the work of linking scroll bars and
canvases.
""" ############################################################################ PyPhoto 1.1: thumbnail image viewer with resizing and saves. Supports multiple image directory thumb windows - the initial img dir is passed in as cmd arg, uses "images" default, or is selected via main window button; later directories are opened by pressing "D" in image view or thumbnail windows. Viewer also scrolls popped-up images that are too large for the screen; still to do: (1) rearrange thumbnails when window resized, based on current window size; (2) [DONE] option to resize images to fit current window size? (3) avoid scrolls if image size is less than window max size: use Label if imgwide <= scrwide and imghigh <= scrhigh? New in 1.1: updated to run in Python 3.1 and latest PIL; New in 1.0: now does a form of (2) above: image is resized to one of the display's dimensions if clicked, and zoomed in or out in 10% increments on key presses; generalize me; caveat: seems to lose quality, pixels after many resizes (this is probably a limitation of PIL) The following scaler adapted from PIL's thumbnail code is similar to the screen height scaler here, but only shrinks: x, y = imgwide, imghigh if x > scrwide: y = max(y * scrwide // x, 1); x = scrwide if y > scrhigh: x = max(x * scrhigh // y, 1); y = scrhigh ############################################################################ """ import sys, math, os from tkinter import * from tkinter.filedialog import SaveAs, Directory from PIL import Image # PIL Image: also in tkinter from PIL.ImageTk import PhotoImage # PIL photo widget replacement from viewer_thumbs import makeThumbs # developed earlier in book # remember last dirs across all windows saveDialog = SaveAs(title='Save As (filename gives image type)') openDialog = Directory(title='Select Image Directory To Open') trace = print # or lambda *x: None appname = 'PyPhoto 1.1: ' class ScrolledCanvas(Canvas): """ a canvas in a container that automatically makes vertical and horizontal scroll bars for itself """ def __init__(self, container): Canvas.__init__(self, container) self.config(borderwidth=0) vbar = Scrollbar(container) hbar = Scrollbar(container, orient='horizontal') vbar.pack(side=RIGHT, fill=Y) # pack canvas after bars hbar.pack(side=BOTTOM, fill=X) # so clipped first self.pack(side=TOP, fill=BOTH, expand=YES) vbar.config(command=self.yview) # call on scroll move hbar.config(command=self.xview) self.config(yscrollcommand=vbar.set) # call on canvas move self.config(xscrollcommand=hbar.set) class ViewOne(Toplevel): """ open a single image in a pop-up window when created; a class because photoimage obj must be saved, else erased if reclaimed; scroll if too big for display; on mouse clicks, resizes to window's height or width: stretches or shrinks; on I/O keypress, zooms in/out; both resizing schemes maintain original aspect ratio; code is factored to avoid redundancy here as possible; """ def __init__(self, imgdir, imgfile, forcesize=()): Toplevel.__init__(self) helptxt = '(click L/R or press I/O to resize, S to save, D to open)' self.title(appname + imgfile + ' ' + helptxt) imgpath = os.path.join(imgdir, imgfile) imgpil = Image.open(imgpath) self.canvas = ScrolledCanvas(self) self.drawImage(imgpil, forcesize) self.canvas.bind('<Button-1>', self.onSizeToDisplayHeight) self.canvas.bind('<Button-3>', self.onSizeToDisplayWidth) self.bind('<KeyPress-i>', self.onZoomIn) self.bind('<KeyPress-o>', self.onZoomOut) self.bind('<KeyPress-s>', self.onSaveImage) self.bind('<KeyPress-d>', onDirectoryOpen) self.focus() def drawImage(self, imgpil, forcesize=()): imgtk = PhotoImage(image=imgpil) # not file=imgpath scrwide, scrhigh = forcesize or self.maxsize() # wm screen size x,y imgwide = imgtk.width() # size in pixels imghigh = imgtk.height() # same as imgpil.size fullsize = (0, 0, imgwide, imghigh) # scrollable viewwide = min(imgwide, scrwide) # viewable viewhigh = min(imghigh, scrhigh) canvas = self.canvas canvas.delete('all') # clear prior photo canvas.config(height=viewhigh, width=viewwide) # viewable window size canvas.config(scrollregion=fullsize) # scrollable area size canvas.create_image(0, 0, image=imgtk, anchor=NW) if imgwide <= scrwide and imghigh <= scrhigh: # too big for display? self.state('normal') # no: win size per img elif sys.platform[:3] == 'win': # do windows fullscreen self.state('zoomed') # others use geometry() self.saveimage = imgpil self.savephoto = imgtk # keep reference on me trace((scrwide, scrhigh), imgpil.size) def sizeToDisplaySide(self, scaler): # resize to fill one side of the display imgpil = self.saveimage scrwide, scrhigh = self.maxsize() # wm screen size x,y imgwide, imghigh = imgpil.size # img size in pixels newwide, newhigh = scaler(scrwide, scrhigh, imgwide, imghigh) if (newwide * newhigh < imgwide * imghigh): filter = Image.ANTIALIAS # shrink: antialias else: # grow: bicub sharper filter = Image.BICUBIC imgnew = imgpil.resize((newwide, newhigh), filter) self.drawImage(imgnew) def onSizeToDisplayHeight(self, event): def scaleHigh(scrwide, scrhigh, imgwide, imghigh): newhigh = scrhigh newwide = int(scrhigh * (imgwide / imghigh)) # 3.x true div return (newwide, newhigh) # proportional self.sizeToDisplaySide(scaleHigh) def onSizeToDisplayWidth(self, event): def scaleWide(scrwide, scrhigh, imgwide, imghigh): newwide = scrwide newhigh = int(scrwide * (imghigh / imgwide)) # 3.x true div return (newwide, newhigh) self.sizeToDisplaySide(scaleWide) def zoom(self, factor): # zoom in or out in increments imgpil = self.saveimage wide, high = imgpil.size if factor < 1.0: # antialias best if shrink filter = Image.ANTIALIAS # also nearest, bilinear else: filter = Image.BICUBIC new = imgpil.resize((int(wide * factor), int(high * factor)), filter) self.drawImage(new) def onZoomIn(self, event, incr=.10): self.zoom(1.0 + incr) def onZoomOut(self, event, decr=.10): self.zoom(1.0 - decr) def onSaveImage(self, event): # save current image state to file filename = saveDialog.show() if filename: self.saveimage.save(filename) def onDirectoryOpen(event): """ open a new image directory in new pop up available in both thumb and img windows """ dirname = openDialog.show() if dirname: viewThumbs(dirname, kind=Toplevel) def viewThumbs(imgdir, kind=Toplevel, numcols=None, height=400, width=500): """ make main or pop-up thumbnail buttons window; uses fixed-size buttons, scrollable canvas; sets scrollable (full) size, and places thumbs at abs x,y coordinates in canvas; no longer assumes all thumbs are same size: gets max of all (x,y), some may be smaller; """ win = kind() helptxt = '(press D to open other)' win.title(appname + imgdir + ' ' + helptxt) quit = Button(win, text='Quit', command=win.quit, bg='beige') quit.pack(side=BOTTOM, fill=X) canvas = ScrolledCanvas(win) canvas.config(height=height, width=width) # init viewable window size # changes if user resizes thumbs = makeThumbs(imgdir) # [(imgfile, imgobj)] numthumbs = len(thumbs) if not numcols: numcols = int(math.ceil(math.sqrt(numthumbs))) # fixed or N x N numrows = int(math.ceil(numthumbs / numcols)) # 3.x true div # max w|h: thumb=(name, obj), thumb.size=(width, height) linksize = max(max(thumb[1].size) for thumb in thumbs) trace(linksize) fullsize = (0, 0, # upper left X,Y (linksize * numcols), (linksize * numrows) ) # lower right X,Y canvas.config(scrollregion=fullsize) # scrollable area size rowpos = 0 savephotos = [] while thumbs: thumbsrow, thumbs = thumbs[:numcols], thumbs[numcols:] colpos = 0 for (imgfile, imgobj) in thumbsrow: photo = PhotoImage(imgobj) link = Button(canvas, image=photo) def handler(savefile=imgfile): ViewOne(imgdir, savefile) link.config(command=handler, width=linksize, height=linksize) link.pack(side=LEFT, expand=YES) canvas.create_window(colpos, rowpos, anchor=NW, window=link, width=linksize, height=linksize) colpos += linksize savephotos.append(photo) rowpos += linksize win.bind('<KeyPress-d>', onDirectoryOpen) win.savephotos = savephotos return win if __name__ == '__main__': """ open dir = default or cmdline arg else show simple window to select """ imgdir = 'images' if len(sys.argv) > 1: imgdir = sys.argv[1] if os.path.exists(imgdir): mainwin = viewThumbs(imgdir, kind=Tk) else: mainwin = Tk() mainwin.title(appname + 'Open') handler = lambda: onDirectoryOpen(None) Button(mainwin, text='Open Image Directory', command=handler).pack() mainwin.mainloop()
A picture may be worth a thousand words, but it takes considerably fewer to display one with Python. The next program, PyView, implements a simple photo slideshow program in portable Python/tkinter code. It doesn’t have any image-processing abilities such as PyPhoto’s resizing, but it does provide different tools, such as image note files, and it can be run without the optional PIL extension.
PyView pulls together many of the topics we studied in Chapter 9: it uses after
events to sequence a slideshow,
displays image objects in an automatically sized canvas, and so on.
Its main window displays a photo on a canvas; users can either open
and view a photo directly or start a slideshow mode that picks and
displays a random photo from a directory at regular intervals
specified with a scale widget.
By default, PyView slideshows show images in the book’s image file directory (though the Open button allows you to load images in arbitrary directories). To view other sets of photos, either pass a directory name in as a first command-line argument or change the default directory name in the script itself. I can’t show you a slideshow in action here, but I can show you the main window in general. Figure 11-12 shows the main PyView window’s default display on Windows 7, created by running the slideShowPlus.py script we’ll see in Example 11-6 ahead.
Though it’s not obvious as rendered in this book, the black-on-red label at the top gives the pathname of the photo file displayed. For a good time, move the slider at the bottom all the way over to “0” to specify no delay between photo changes, and then click Start to begin a very fast slideshow. If your computer is at least as fast as mine, photos flip by much too fast to be useful for anything but subliminal advertising. Slideshow photos are loaded on startup to retain references to them (remember, you must hold on to image objects). But the speed with which large GIFs can be thrown up in a window in Python is impressive, if not downright exhilarating.
The GUI’s Start button changes to a Stop button during a
slideshow (its text attribute is reset with the widget config
method). Figure 11-13 shows the scene after
pressing Stop at an opportune moment.
In addition, each photo can have an associated “notes” text file that is automatically opened along with the image. You can use this feature to record basic information about the photo. Press the Note button to open an additional set of widgets that let you view and change the note file associated with the currently displayed photo. This additional set of widgets should look familiar—the PyEdit text editor from earlier in this chapter is attached to PyView in a variety of selectable modes to serve as a display and editing widget for photo notes. Figure 11-14 shows PyView with the attached PyEdit note-editing component opened (I resized the window a bit interactively for presentation here).
This makes for a very big window, usually best viewed maximized (taking up the entire screen). The main thing to notice, though, is the lower-right corner of this display, above the scale—it’s simply an attached PyEdit object, running the very same code listed in the earlier section. Because PyEdit is implemented as a GUI class, it can be reused like this in any GUI that needs a text-editing interface.
When embedded this way, PyEdit is a nested frame attached to a slideshow frame. Its menus are based on a frame (it doesn’t own the window at large), text content is stored and fetched directly by the enclosing program, and some standalone options are omitted (e.g., the File pull down menu and Quit button are gone). On the other hand, you get all of the rest of PyEdit’s functionality, including cut and paste, search and replace, grep to search external files, colors and fonts, undo and redo, and so on. Even the Clone option works here to open a new edit window, albeit making a frame-based menu without a Quit or File pull down, and which doesn’t test for changes on exit—a usage mode that could be tightened up with a new PyEdit top-level component class if desired.
For variety, if you pass a third command-line argument to
PyView after the image directory name, it uses it as an index into
a list of PyEdit top-level mode classes. An argument of 0 uses the
main window mode, which places the note editor below the image and
a window menu at top (its Frame
is packed into the window’s remaining space, not the slide show
frame); 1 pops up the note editor as a separate, independent
Toplevel
window (disabled when
notes are turned off); 2 and 3 run PyEdit as an embedded component
nested in the slide show frame, with Frame
menus (2 includes all menu options
which may or may not be appropriate in this role, and 3 is the
default limited options mode).
Figure 11-15 captures option 0, PyEdit’s main window mode; there are really two independent frames on the window here—a slideshow on top and a text editor on bottom. The disadvantage of this over nested component or pop-up window modes is that PyEdit really does assume control of the program’s window (including its title and window manager close button), and packing the note editor at the bottom means it might not appear for tall images. Run this on your own to sample the other PyEdit flavors, with a command line of this form:
C:...PP4EGuiSlideShow> slideShowPlus.py ../gifs 0
The note file viewer appears only if you press the Note
button, and it is erased if you press it again—PyView uses the
widget pack
and pack_forget
methods introduced near the
end of Chapter 9 to show and
hide the note viewer frame. The window automatically expands to
accommodate the note viewer when it is packed and displayed. Note
that it’s important that the note editor be repacked with expand=YES
and fill=BOTH
when it’s unhidden, or else it
won’t expand in some modes; PyEdit’s frame packs itself this way
in GuiMaker
when first made,
but pack_forget
appears to,
well…forget.
It is also possible to open the note file in a PyEdit pop-up
window, but PyView embeds the editor by default to retain a direct
visual association and avoid issues if the pop up is destroyed
independently. As is, this program must wrap the PyEdit classes
with its WrapEditor
in order to
catch independent destroys of the PyEdit frame when it is run in
either pop-up window or full-option component modes—the note
editor can no longer be accessed or repacked once it’s destroyed.
This isn’t an issue in main window mode (Quit ends the program) or
the default minimal component mode (the editor has no Quit). Watch
for PyEdit to show up embedded as a component like this within
another GUI when we meet PyMailGUI in Chapter 14.
A caveat here: out of the box, PyView supports as many photo
formats as tkinter’s PhotoImage
object does; that’s why it looks for GIF files by default. You can
improve this by installing the PIL extension to view JPEGs (and
many others). But because PIL is an optional extension today, it’s
not incorporated into this PyView release. See the end of Chapter 8 for more on PIL and image
formats.
Because the PyView program was implemented in stages, you need to study the union of two files and classes to understand how it truly works. One file implements a class that provides core slideshow functionality; the other implements a class that extends the original class, to add additional features on top of the core behavior. Let’s start with the extension class: Example 11-6 adds a set of features to an imported slideshow base class—note editing, a delay scale and file label, and so on. This is the file that is actually run to start PyView.
""" ############################################################################# PyView 1.2: an image slide show with associated text notes. SlideShow subclass which adds note files with an attached PyEdit object, a scale for setting the slideshow delay interval, and a label that gives the name of the image file currently being displayed; Version 1.2 is a Python 3.x port, but also improves repacking note for expansion when it's unhidden, catches note destroys in a subclass to avoid exceptions when popup window or full component editor has been closed, and runs update() before inserting text into newly packed note so it is positioned correctly at line 1 (see the book's coverage of PyEdit updates). ############################################################################# """ import os from tkinter import * from PP4E.Gui.TextEditor.textEditor import * from slideShow import SlideShow #from slideShow_threads import SlideShow Size = (300, 550) # 1.2: start shorter here, (h, w) class SlideShowPlus(SlideShow): def __init__(self, parent, picdir, editclass, msecs=2000, size=Size): self.msecs = msecs self.editclass = editclass SlideShow.__init__(self, parent, picdir, msecs, size) def makeWidgets(self): self.name = Label(self, text='None', bg='red', relief=RIDGE) self.name.pack(fill=X) SlideShow.makeWidgets(self) Button(self, text='Note', command=self.onNote).pack(fill=X) Button(self, text='Help', command=self.onHelp).pack(fill=X) s = Scale(label='Speed: msec delay', command=self.onScale, from_=0, to=3000, resolution=50, showvalue=YES, length=400, tickinterval=250, orient='horizontal') s.pack(side=BOTTOM, fill=X) s.set(self.msecs) # 1.2: need to know if editor destroyed, in popup or full component modes self.editorGone = False class WrapEditor(self.editclass): # extend PyEdit class to catch Quit def onQuit(editor): # editor is PyEdit instance arg subject self.editorGone = True # self is slide show in enclosing scope self.editorUp = False self.editclass.onQuit(editor) # avoid recursion # attach editor frame to window or slideshow frame if issubclass(WrapEditor, TextEditorMain): # make editor now self.editor = WrapEditor(self.master) # need root for menu else: self.editor = WrapEditor(self) # embedded or pop-up self.editor.pack_forget() # hide editor initially self.editorUp = self.image = None def onStart(self): SlideShow.onStart(self) self.config(cursor='watch') def onStop(self): SlideShow.onStop(self) self.config(cursor='hand2') def onOpen(self): SlideShow.onOpen(self) if self.image: self.name.config(text=os.path.split(self.image[0])[1]) self.config(cursor='crosshair') self.switchNote() def quit(self): self.saveNote() SlideShow.quit(self) def drawNext(self): SlideShow.drawNext(self) if self.image: self.name.config(text=os.path.split(self.image[0])[1]) self.loadNote() def onScale(self, value): self.msecs = int(value) def onNote(self): if self.editorGone: # 1.2: has been destroyed return # don't rebuild: assume unwanted if self.editorUp: #self.saveNote() # if editor already open self.editor.pack_forget() # save text?, hide editor self.editorUp = False else: # 1.2: repack for expansion again, else won't expand now # 1.2: update between pack and insert, else @ line 2 initially self.editor.pack(side=TOP, expand=YES, fill=BOTH) self.editorUp = True # else unhide/pack editor self.update() # see Pyedit: same as loadFirst issue self.loadNote() # and load image note text def switchNote(self): if self.editorUp: self.saveNote() # save current image's note self.loadNote() # load note for new image def saveNote(self): if self.editorUp: currfile = self.editor.getFileName() # or self.editor.onSave() currtext = self.editor.getAllText() # but text may be empty if currfile and currtext: try: open(currfile, 'w').write(currtext) except: pass # failure may be normal if run off a cd def loadNote(self): if self.image and self.editorUp: root, ext = os.path.splitext(self.image[0]) notefile = root + '.note' self.editor.setFileName(notefile) try: self.editor.setAllText(open(notefile).read()) except: self.editor.clearAllText() # might not have a note def onHelp(self): showinfo('About PyView', 'PyView version 1.2 May, 2010 (1.1 July, 1999) ' 'An image slide show Programming Python 4E') if __name__ == '__main__': import sys picdir = '../gifs' if len(sys.argv) >= 2: picdir = sys.argv[1] editstyle = TextEditorComponentMinimal if len(sys.argv) == 3: try: editstyle = [TextEditorMain, TextEditorMainPopup, TextEditorComponent, TextEditorComponentMinimal][int(sys.argv[2])] except: pass root = Tk() root.title('PyView 1.2 - plus text notes') Label(root, text="Slide show subclass").pack() SlideShowPlus(parent=root, picdir=picdir, editclass=editstyle) root.mainloop()
The core functionality extended by SlideShowPlus
lives in Example 11-7. This was the
initial slideshow implementation; it opens images, displays photos,
and cycles through a slideshow. You can run it by itself, but you
won’t get advanced features such as notes and sliders added by the
SlideShowPlus
subclass.
""" ###################################################################### SlideShow: a simple photo image slideshow in Python/tkinter; the base feature set coded here can be extended in subclasses; ###################################################################### """ from tkinter import * from glob import glob from tkinter.messagebox import askyesno from tkinter.filedialog import askopenfilename import random Size = (450, 450) # canvas height, width at startup and slideshow start imageTypes = [('Gif files', '.gif'), # for file open dialog ('Ppm files', '.ppm'), # plus jpg with a Tk patch, ('Pgm files', '.pgm'), # plus bitmaps with BitmapImage ('All files', '*')] class SlideShow(Frame): def __init__(self, parent=None, picdir='.', msecs=3000, size=Size, **args): Frame.__init__(self, parent, **args) self.size = size self.makeWidgets() self.pack(expand=YES, fill=BOTH) self.opens = picdir files = [] for label, ext in imageTypes[:-1]: files = files + glob('%s/*%s' % (picdir, ext)) self.images = [(x, PhotoImage(file=x)) for x in files] self.msecs = msecs self.beep = True self.drawn = None def makeWidgets(self): height, width = self.size self.canvas = Canvas(self, bg='white', height=height, width=width) self.canvas.pack(side=LEFT, fill=BOTH, expand=YES) self.onoff = Button(self, text='Start', command=self.onStart) self.onoff.pack(fill=X) Button(self, text='Open', command=self.onOpen).pack(fill=X) Button(self, text='Beep', command=self.onBeep).pack(fill=X) Button(self, text='Quit', command=self.onQuit).pack(fill=X) def onStart(self): self.loop = True self.onoff.config(text='Stop', command=self.onStop) self.canvas.config(height=self.size[0], width=self.size[1]) self.onTimer() def onStop(self): self.loop = False self.onoff.config(text='Start', command=self.onStart) def onOpen(self): self.onStop() name = askopenfilename(initialdir=self.opens, filetypes=imageTypes) if name: if self.drawn: self.canvas.delete(self.drawn) img = PhotoImage(file=name) self.canvas.config(height=img.height(), width=img.width()) self.drawn = self.canvas.create_image(2, 2, image=img, anchor=NW) self.image = name, img def onQuit(self): self.onStop() self.update() if askyesno('PyView', 'Really quit now?'): self.quit() def onBeep(self): self.beep = not self.beep # toggle, or use ^ 1 def onTimer(self): if self.loop: self.drawNext() self.after(self.msecs, self.onTimer) def drawNext(self): if self.drawn: self.canvas.delete(self.drawn) name, img = random.choice(self.images) self.drawn = self.canvas.create_image(2, 2, image=img, anchor=NW) self.image = name, img if self.beep: self.bell() self.canvas.update() if __name__ == '__main__': import sys if len(sys.argv) == 2: picdir = sys.argv[1] else: picdir = '../gifs' root = Tk() root.title('PyView 1.2') root.iconname('PyView') Label(root, text="Python Slide Show Viewer").pack() SlideShow(root, picdir=picdir, bd=3, relief=SUNKEN) root.mainloop()
To give you a better idea of what this core base class
implements, Figure 11-16 shows
what it looks like if run by itself—actually, two copies run by
themselves by a script called slideShow_frames
, which is in this book’s
examples distribution, and whose main code looks like this:
root = Tk() Label(root, text="Two embedded slide shows: Frames").pack() SlideShow(parent=root, picdir=picdir, bd=3, relief=SUNKEN).pack(side=LEFT) SlideShow(parent=root, picdir=picdir, bd=3, relief=SUNKEN).pack(side=RIGHT) root.mainloop()
The simple slideShow_frames
scripts attach two instances of SlideShow
to a single window—a feat
possible only because state information is recorded in class
instance variables, not in globals. The slideShow_toplevels
script (also in the
book’s examples distribution) attaches two SlideShow
s to two top-level pop-up windows
instead. In both cases, the slideshows run independently but are
based on after
events fired from
the same single event loop in a single process.
Chapter 9 introduced simple tkinter animation techniques (see the
tour’s canvasDraw
variants). The
PyDraw program listed here builds upon those ideas to implement a more
feature-rich painting program in Python. It adds new trails and
scribble drawing modes, object and background color fills, embedded
photos, and more. In addition, it implements object movement and
animation techniques—drawn objects may be moved around the canvas by
clicking and dragging, and any drawn object can be gradually moved
across the screen to a target location clicked with the mouse.
PyDraw is essentially a tkinter canvas with keyboard and mouse event bindings to allow users to perform common drawing operations. This isn’t a professional-grade paint program, but it’s fun to play with. In fact, you really should—it is impossible to capture things such as object motion in this book. Start PyDraw from the launcher bars (or run the file movingpics.py from Example 11-8 directly). Press the ? key to view a help message giving available commands (or read the help string in the code listings).
Figure 11-17 shows PyDraw after a few objects have been drawn on the canvas. To move any object shown here, either click it with the middle mouse button and drag to move it with the mouse cursor, or middle-click the object, and then right-click in the spot you want it to move toward. In the latter case, PyDraw performs an animated (gradual) movement of the object to the target spot. Try this on the picture shown near the top of the figure—it will slowly move across your display.
Press “p” to insert photos, and use left-button drags to draw shapes. (Note to Windows users: middle-click is often either both mouse buttons at once or a scroll wheel, but you may need to configure this in your control panel.) In addition to mouse events, there are 17 key-press commands for tailoring sketches that I won’t cover here. It takes a while to get the hang of what all the keyboard and mouse commands do, but once you’ve mastered the bindings, you too can begin generating senseless electronic artwork such as that in Figure 11-18.
Like PyEdit, PyDraw lives in a single file. Two extensions that customize motion implementations are listed following the main module shown in Example 11-8.
""" ############################################################################## PyDraw 1.1: simple canvas paint program and object mover/animator. Uses time.sleep loops to implement object move loops, such that only one move can be in progress at once; this is smooth and fast, but see the widget.after and thread-based subclasses here for other techniques. Version 1.1 has been updated to run under Python 3.X (2.X not supported) ############################################################################## """ helpstr = """--PyDraw version 1.1-- Mouse commands: Left = Set target spot Left+Move = Draw new object Double Left = Clear all objects Right = Move current object Middle = Select closest object Middle+Move = Drag current object Keyboard commands: w=Pick border width c=Pick color u=Pick move unit s=Pick move delay o=Draw ovals r=Draw rectangles l=Draw lines a=Draw arcs d=Delete object 1=Raise object 2=Lower object f=Fill object b=Fill background p=Add photo z=Save postscript x=Pick pen modes ?=Help other=clear text """ import time, sys from tkinter import * from tkinter.filedialog import * from tkinter.messagebox import * PicDir = '../gifs' if sys.platform[:3] == 'win': HelpFont = ('courier', 9, 'normal') else: HelpFont = ('courier', 12, 'normal') pickDelays = [0.01, 0.025, 0.05, 0.10, 0.25, 0.0, 0.001, 0.005] pickUnits = [1, 2, 4, 6, 8, 10, 12] pickWidths = [1, 2, 5, 10, 20] pickFills = [None,'white','blue','red','black','yellow','green','purple'] pickPens = ['elastic', 'scribble', 'trails'] class MovingPics: def __init__(self, parent=None): canvas = Canvas(parent, width=500, height=500, bg= 'white') canvas.pack(expand=YES, fill=BOTH) canvas.bind('<ButtonPress-1>', self.onStart) canvas.bind('<B1-Motion>', self.onGrow) canvas.bind('<Double-1>', self.onClear) canvas.bind('<ButtonPress-3>', self.onMove) canvas.bind('<Button-2>', self.onSelect) canvas.bind('<B2-Motion>', self.onDrag) parent.bind('<KeyPress>', self.onOptions) self.createMethod = Canvas.create_oval self.canvas = canvas self.moving = [] self.images = [] self.object = None self.where = None self.scribbleMode = 0 parent.title('PyDraw - Moving Pictures 1.1') parent.protocol('WM_DELETE_WINDOW', self.onQuit) self.realquit = parent.quit self.textInfo = self.canvas.create_text( 5, 5, anchor=NW, font=HelpFont, text='Press ? for help') def onStart(self, event): self.where = event self.object = None def onGrow(self, event): canvas = event.widget if self.object and pickPens[0] == 'elastic': canvas.delete(self.object) self.object = self.createMethod(canvas, self.where.x, self.where.y, # start event.x, event.y, # stop fill=pickFills[0], width=pickWidths[0]) if pickPens[0] == 'scribble': self.where = event # from here next time def onClear(self, event): if self.moving: return # ok if moving but confusing event.widget.delete('all') # use all tag self.images = [] self.textInfo = self.canvas.create_text( 5, 5, anchor=NW, font=HelpFont, text='Press ? for help') def plotMoves(self, event): diffX = event.x - self.where.x # plan animated moves diffY = event.y - self.where.y # horizontal then vertical reptX = abs(diffX) // pickUnits[0] # incr per move, number moves reptY = abs(diffY) // pickUnits[0] # from last to event click incrX = pickUnits[0] * ((diffX > 0) or −1) # 3.x // trunc div required incrY = pickUnits[0] * ((diffY > 0) or −1) return incrX, reptX, incrY, reptY def onMove(self, event): traceEvent('onMove', event, 0) # move current object to click object = self.object # ignore some ops during mv if object and object not in self.moving: msecs = int(pickDelays[0] * 1000) parms = 'Delay=%d msec, Units=%d' % (msecs, pickUnits[0]) self.setTextInfo(parms) self.moving.append(object) canvas = event.widget incrX, reptX, incrY, reptY = self.plotMoves(event) for i in range(reptX): canvas.move(object, incrX, 0) canvas.update() time.sleep(pickDelays[0]) for i in range(reptY): canvas.move(object, 0, incrY) canvas.update() # update runs other ops time.sleep(pickDelays[0]) # sleep until next move self.moving.remove(object) if self.object == object: self.where = event def onSelect(self, event): self.where = event self.object = self.canvas.find_closest(event.x, event.y)[0] # tuple def onDrag(self, event): diffX = event.x - self.where.x # OK if object in moving diffY = event.y - self.where.y # throws it off course self.canvas.move(self.object, diffX, diffY) self.where = event def onOptions(self, event): keymap = { 'w': lambda self: self.changeOption(pickWidths, 'Pen Width'), 'c': lambda self: self.changeOption(pickFills, 'Color'), 'u': lambda self: self.changeOption(pickUnits, 'Move Unit'), 's': lambda self: self.changeOption(pickDelays, 'Move Delay'), 'x': lambda self: self.changeOption(pickPens, 'Pen Mode'), 'o': lambda self: self.changeDraw(Canvas.create_oval, 'Oval'), 'r': lambda self: self.changeDraw(Canvas.create_rectangle, 'Rect'), 'l': lambda self: self.changeDraw(Canvas.create_line, 'Line'), 'a': lambda self: self.changeDraw(Canvas.create_arc, 'Arc'), 'd': MovingPics.deleteObject, '1': MovingPics.raiseObject, '2': MovingPics.lowerObject, # if only 1 call pattern 'f': MovingPics.fillObject, # use unbound method objects 'b': MovingPics.fillBackground, # else lambda passed self 'p': MovingPics.addPhotoItem, 'z': MovingPics.savePostscript, '?': MovingPics.help} try: keymap[event.char](self) except KeyError: self.setTextInfo('Press ? for help') def changeDraw(self, method, name): self.createMethod = method # unbound Canvas method self.setTextInfo('Draw Object=' + name) def changeOption(self, list, name): list.append(list[0]) del list[0] self.setTextInfo('%s=%s' % (name, list[0])) def deleteObject(self): if self.object != self.textInfo: # ok if object in moving self.canvas.delete(self.object) # erases but move goes on self.object = None def raiseObject(self): if self.object: # ok if moving self.canvas.tkraise(self.object) # raises while moving def lowerObject(self): if self.object: self.canvas.lower(self.object) def fillObject(self): if self.object: type = self.canvas.type(self.object) if type == 'image': pass elif type == 'text': self.canvas.itemconfig(self.object, fill=pickFills[0]) else: self.canvas.itemconfig(self.object, fill=pickFills[0], width=pickWidths[0]) def fillBackground(self): self.canvas.config(bg=pickFills[0]) def addPhotoItem(self): if not self.where: return filetypes=[('Gif files', '.gif'), ('All files', '*')] file = askopenfilename(initialdir=PicDir, filetypes=filetypes) if file: image = PhotoImage(file=file) # load image self.images.append(image) # keep reference self.object = self.canvas.create_image( # add to canvas self.where.x, self.where.y, # at last spot image=image, anchor=NW) def savePostscript(self): file = asksaveasfilename() if file: self.canvas.postscript(file=file) # save canvas to file def help(self): self.setTextInfo(helpstr) #showinfo('PyDraw', helpstr) def setTextInfo(self, text): self.canvas.dchars(self.textInfo, 0, END) self.canvas.insert(self.textInfo, 0, text) self.canvas.tkraise(self.textInfo) def onQuit(self): if self.moving: self.setTextInfo("Can't quit while move in progress") else: self.realquit() # std wm delete: err msg if move in progress def traceEvent(label, event, fullTrace=True): print(label) if fullTrace: for attr in dir(event): if attr[:2] != '__': print(attr, '=>', getattr(event, attr)) if __name__ == '__main__': from sys import argv # when this file is executed if len(argv) == 2: PicDir = argv[1] # '..' fails if run elsewhere root = Tk() # make, run a MovingPics object MovingPics(root) root.mainloop()
As is, only one object can be in motion at a time—requesting
an object move while one is already in motion pauses the original
till the new move is finished. Just as in Chapter 9’s canvasDraw
examples, though, we can add
support for moving more than one object at the same time with either
after
scheduled callback events
or threads.
Example 11-9
shows a MovingPics
subclass that
codes the necessary customizations to do parallel moves with
after
events. It allows any
number of objects in the canvas, including pictures, to be moving
independently at once. Run this file directly to see the difference;
I could try to capture the notion of multiple objects in motion with
a screenshot, but I would almost certainly fail.
""" PyDraw-after: simple canvas paint program and object mover/animator use widget.after scheduled events to implement object move loops, such that more than one can be in motion at once without having to use threads; this does moves in parallel, but seems to be slower than time.sleep version; see also canvasDraw in Tour: builds and passes the incX/incY list at once: here, would be allmoves = ([(incrX, 0)] * reptX) + ([(0, incrY)] * reptY) """ from movingpics import * class MovingPicsAfter(MovingPics): def doMoves(self, delay, objectId, incrX, reptX, incrY, reptY): if reptX: self.canvas.move(objectId, incrX, 0) reptX -= 1 else: self.canvas.move(objectId, 0, incrY) reptY -= 1 if not (reptX or reptY): self.moving.remove(objectId) else: self.canvas.after(delay, self.doMoves, delay, objectId, incrX, reptX, incrY, reptY) def onMove(self, event): traceEvent('onMove', event, 0) object = self.object # move cur obj to click spot if object: msecs = int(pickDelays[0] * 1000) parms = 'Delay=%d msec, Units=%d' % (msecs, pickUnits[0]) self.setTextInfo(parms) self.moving.append(object) incrX, reptX, incrY, reptY = self.plotMoves(event) self.doMoves(msecs, object, incrX, reptX, incrY, reptY) self.where = event if __name__ == '__main__': from sys import argv # when this file is executed if len(argv) == 2: import movingpics # not this module's global movingpics.PicDir = argv[1] # and from* doesn't link names root = Tk() MovingPicsAfter(root) root.mainloop()
To appreciate its operation, open this script’s window full
screen and create some objects in its canvas by pressing “p” after
an initial click to insert pictures, dragging out shapes, and so on.
Now, while one or more moves are in progress, you can start another
by middle-clicking on another object and right-clicking on the spot
to which you want it to move. It starts its journey immediately,
even if other objects are in motion. Each object’s scheduled
after
events are added to the
same event loop queue and dispatched by tkinter as soon as possible
after a timer expiration.
If you run this subclass module directly, you might notice that movement isn’t quite as fast or as smooth as in the original (depending on your machine, and the many layers of software under Python), but multiple moves can overlap in time.
Example 11-10 shows how to achieve such parallelism with threads. This process works, but as we learned in Chapters 9 and 10, updating GUIs in spawned threads is generally a dangerous affair. On one of my machines, the movement that this script implements with threads was a bit jerkier than the original version—perhaps a reflection of the overhead incurred for switching the interpreter (and CPU) between multiple threads—but again, this can vary.
""" PyDraw-threads: use threads to move objects; works okay on Windows provided that canvas.update() not called by threads (else exits with fatal errors, some objs start moving immediately after drawn, etc.); at least some canvas method calls must be thread safe in tkinter; this is less smooth than time.sleep, and is dangerous in general: threads are best coded to update global vars, not change GUI; """ import _thread as thread, time, sys, random from tkinter import Tk, mainloop from movingpics import MovingPics, pickUnits, pickDelays class MovingPicsThreaded(MovingPics): def __init__(self, parent=None): MovingPics.__init__(self, parent) self.mutex = thread.allocate_lock() import sys #sys.setcheckinterval(0) # switch after each vm op: doesn't help def onMove(self, event): object = self.object if object and object not in self.moving: msecs = int(pickDelays[0] * 1000) parms = 'Delay=%d msec, Units=%d' % (msecs, pickUnits[0]) self.setTextInfo(parms) #self.mutex.acquire() self.moving.append(object) #self.mutex.release() thread.start_new_thread(self.doMove, (object, event)) def doMove(self, object, event): canvas = event.widget incrX, reptX, incrY, reptY = self.plotMoves(event) for i in range(reptX): canvas.move(object, incrX, 0) # canvas.update() time.sleep(pickDelays[0]) # this can change for i in range(reptY): canvas.move(object, 0, incrY) # canvas.update() # update runs other ops time.sleep(pickDelays[0]) # sleep until next move #self.mutex.acquire() self.moving.remove(object) if self.object == object: self.where = event #self.mutex.release() if __name__ == '__main__': root = Tk() MovingPicsThreaded(root) mainloop()
One of the first things I always look for when exploring a
new computer interface is a clock. Because I spend so much time glued
to computers, it’s essentially impossible for me to keep track of the
time unless it is right there on the screen in front of me (and even
then, it’s iffy). The next program, PyClock, implements such a clock
widget in Python. It’s not substantially different from the clock
programs that you may be used to seeing on the X Window System.
Because it is coded in Python, though, this one is both easily
customized and fully portable among Windows, the X Window System, and
Macs, like all the code in this chapter. In addition to advanced GUI
techniques, this example demonstrates Python math
and time
module tools.
Before I show you PyClock, though, let me provide a little background and a confession. Quick—how do you plot points on a circle? This, along with time formats and events, turns out to be a core concept in clock widget programs. To draw an analog clock face on a canvas widget, you essentially need to be able to sketch a circle—the clock face itself is composed of points on a circle, and the second, minute, and hour hands of the clock are really just lines from a circle’s center out to a point on the circle. Digital clocks are simpler to draw, but not much to look at.
Now the confession: when I started writing PyClock, I couldn’t answer the last paragraph’s opening question. I had utterly forgotten the math needed to sketch out points on a circle (as had most of the professional software developers I queried about this magic formula). It happens. After going unused for a few decades, such knowledge tends to be garbage collected. I finally was able to dust off a few neurons long enough to code the plotting math needed, but it wasn’t my finest intellectual hour.[40]
If you are in the same boat, I don’t have space to teach
geometry in depth here, but I can show you one way to code the
point-plotting formulas in Python in simple terms. Before tackling
the more complex task of implementing a clock, I wrote the plotterGui
script shown in Example 11-11 to focus on just
the circle-plotting logic.
Its point
function is where
the circle logic lives—it plots the (X,Y) coordinates of a point on
the circle, given the relative point number, the total number of
points to be placed on the circle, and the circle’s radius (the
distance from the circle’s center to the points drawn upon it). It
first calculates the point’s angle from the top by dividing 360 by
the number of points to be plotted, and then multiplying by the
point number; in case you’ve forgotten too, it’s 360 degrees around
the whole circle (e.g., if you plot 4 points on a circle, each is 90
degrees from the last, or 360/4). Python’s standard math
module gives all the required
constants and functions from that point forward—pi, sine, and
cosine. The math is really not too obscure if you study this long
enough (in conjunction with your old geometry text if necessary).
There are alternative ways to code the number crunching, but I’ll
skip the details here (see the examples package for hints).
Even if you don’t care about the math, though, check out Example 11-11’s circle
function. Given the (X,Y)
coordinates of a point on the circle returned by point
, it draws a line from the circle’s
center out to the point and a small rectangle around the point
itself—not unlike the hands and points of an analog clock. Canvas
tags are used to associate drawn objects for deletion before each
plot.
# plot circles on a canvas import math, sys from tkinter import * def point(tick, range, radius): angle = tick * (360.0 / range) radiansPerDegree = math.pi / 180 pointX = int( round( radius * math.sin(angle * radiansPerDegree) )) pointY = int( round( radius * math.cos(angle * radiansPerDegree) )) return (pointX, pointY) def circle(points, radius, centerX, centerY, slow=0): canvas.delete('lines') canvas.delete('points') for i in range(points): x, y = point(i+1, points, radius-4) scaledX, scaledY = (x + centerX), (centerY - y) canvas.create_line(centerX, centerY, scaledX, scaledY, tag='lines') canvas.create_rectangle(scaledX-2, scaledY-2, scaledX+2, scaledY+2, fill='red', tag='points') if slow: canvas.update() def plotter(): # 3.x // trunc div circle(scaleVar.get(), (Width // 2), originX, originY, checkVar.get()) def makewidgets(): global canvas, scaleVar, checkVar canvas = Canvas(width=Width, height=Width) canvas.pack(side=TOP) scaleVar = IntVar() checkVar = IntVar() scale = Scale(label='Points on circle', variable=scaleVar, from_=1, to=360) scale.pack(side=LEFT) Checkbutton(text='Slow mode', variable=checkVar).pack(side=LEFT) Button(text='Plot', command=plotter).pack(side=LEFT, padx=50) if __name__ == '__main__': Width = 500 # default width, height if len(sys.argv) == 2: Width = int(sys.argv[1]) # width cmdline arg? originX = originY = Width // 2 # same as circle radius makewidgets() # on default Tk root mainloop() # need 3.x // trunc div
The circle defaults to 500 pixels wide unless you pass a width
on the command line. Given a number of points on a circle, this
script marks out the circle in clockwise order every time you press
Plot, by drawing lines out from the center to small rectangles at
points on the circle’s shape. Move the slider to plot a different
number of points and click the checkbox to make the drawing happen
slow enough to notice the clockwise order in which lines and points
are drawn (this forces the script to update
the display after each line is
drawn). Figure 11-19 shows the result for
plotting 120 points with the circle width set to 400 on the command
line; if you ask for 60 and 12 points on the circle, the
relationship to clock faces and hands starts becoming
clearer.
For more help, this book’s examples distribution also includes
text-based versions of this plotting script that print circle point
coordinates to the stdout
stream
for review, instead of rendering them in a GUI. See the plotterText.py scripts in the clock’s
directory. Here is the sort of
output they produce when plotting 4 and 12 points on a circle that
is 400 points wide and high; the output format is simply:
pointnumber : angle = (Xcoordinate, Ycoordinate)
and assumes that the circle is centered at coordinate (0,0):
---------- 1 : 90.0 = (200, 0) 2 : 180.0 = (0, −200) 3 : 270.0 = (−200, 0) 4 : 360.0 = (0, 200) ---------- 1 : 30.0 = (100, 173) 2 : 60.0 = (173, 100) 3 : 90.0 = (200, 0) 4 : 120.0 = (173, −100) 5 : 150.0 = (100, −173) 6 : 180.0 = (0, −200) 7 : 210.0 = (−100, −173) 8 : 240.0 = (−173, −100) 9 : 270.0 = (−200, 0) 10 : 300.0 = (−173, 100) 11 : 330.0 = (−100, 173) 12 : 360.0 = (0, 200) ----------
To understand how these points are mapped to a canvas, you first need to know that the width and height of a circle are always the same—the radius × 2. Because tkinter canvas (X,Y) coordinates start at (0,0) in the upper-left corner, the plotter GUI must offset the circle’s center point to coordinates (width/2, width/2)—the origin point from which lines are drawn. For instance, in a 400 × 400 circle, the canvas center is (200,200). A line to the 90-degree angle point on the right side of the circle runs from (200,200) to (400,200)—the result of adding the (200,0) point coordinates plotted for the radius and angle. A line to the bottom at 180 degrees runs from (200,200) to (200,400) after factoring in the (0,-200) point plotted.
This point-plotting algorithm used by plotterGui
, along with a few scaling
constants, is at the heart of the PyClock analog display. If this
still seems a bit much, I suggest you focus on the PyClock script’s
digital display implementation first; the
analog geometry plots are really just extensions of underlying
timing mechanisms used for both display modes. In fact, the clock
itself is structured as a generic Frame
object that
embeds digital and analog display objects and
dispatches time change and resize events to both in the same way.
The analog display is an attached Canvas
that knows how to draw circles, but
the digital object is simply an attached Frame
with labels to show time components.
Apart from the circle geometry bit, the rest of PyClock is
straightforward. It simply draws a clock face to represent the
current time and uses widget after
methods to wake itself up 10 times
per second to check whether the system time has rolled over to the
next second. On second rollovers, the analog second, minute, and
hour hands are redrawn to reflect the new time (or the text of the
digital display’s labels is changed). In terms of GUI construction,
the analog display is etched out on a canvas, redrawn whenever the
window is resized, and changes to a digital format upon
request.
PyClock also puts Python’s standard time
module into service to fetch and
convert system time information as needed for a clock. In brief, the
onTimer
method gets system time
with time.time
, a built-in tool
that returns a floating-point number giving seconds since the
epoch—the point from which your computer counts
time. The time.localtime
call is
then used to convert epoch time into a tuple that contains hour,
minute, and second values; see the script and Python library manual
for additional details.
Checking the system time 10 times per second may seem intense,
but it guarantees that the second hand ticks when it should, without
jerks or skips (after
events
aren’t precisely timed). It is not a significant CPU drain on
systems I use. On Linux and Windows, PyClock uses negligible
processor resources—what it does use is spent largely on screen
updates in analog display mode, not on after
events.[41]
To minimize screen updates, PyClock redraws only clock hands on second rollovers; points on the clock’s circle are redrawn only at startup and on window resizes. Figure 11-20 shows the default initial PyClock display format you get when the file clock.py is run directly.
The clock hand lines are given arrows at their endpoints with
the canvas line object’s arrow
and arrowshape
options. The
arrow
option can be first
, last
, none
, or both
; the arrowshape
option takes a tuple giving the
length of the arrow touching the line, its overall length, and its
width.
Like PyView, PyClock also uses the widget pack_forget
and pack
methods to dynamically erase and
redraw portions of the display on demand (i.e., in response to bound
events). Clicking on the clock with a left mouse button changes its
display to digital by erasing the analog widgets and drawing the
digital interface; you get the simpler display captured in Figure 11-21.
This digital display form is useful if you want to conserve real estate on your computer screen and minimize PyClock CPU utilization (it incurs very little screen update overhead). Left-clicking on the clock again changes back to the analog display. The analog and digital displays are both constructed when the script starts, but only one is ever packed at any given time.
A right mouse click on the clock in either display mode shows or hides an attached label that gives the current date in simple text form. Figure 11-22 shows a PyClock running with an analog display, a clicked-on date label, and a centered photo image object (this is clock style started by the PyGadgets launcher):
The image in the middle of Figure 11-22 is added by passing in a configuration object with appropriate settings to the PyClock object constructor. In fact, almost everything about this display can be customized with attributes in PyClock configuration objects—hand colors, clock tick colors, center photos, and initial size.
Because PyClock’s analog display is based upon a manually
sketched figure on a canvas, it has to process window
resize events itself: whenever the window
shrinks or expands, the clock face has to be redrawn and scaled for
the new window size. To catch screen resizes, the script registers
for the <Configure>
event
with bind
; surprisingly, this
isn’t a top-level window manager event like the Close button. As you
expand a PyClock, the clock face gets bigger with the window—try
expanding, shrinking, and maximizing the clock window on your
computer. Because the clock face is plotted in a square coordinate
system, PyClock always expands in equal horizontal and vertical
proportions, though; if you simply make the window only wider or
taller, the clock is unchanged.
Added in the third edition of this book is a countdown timer feature: press the “s” or “m” key to pop up a simple dialog for entering the number of seconds or minutes for the countdown, respectively. Once the countdown expires, the pop up in Figure 11-23 appears and fills the entire screen on Windows. I sometimes use this in classes I teach to remind myself and my students when it’s time to move on (the effect is more striking when this pop up is projected onto an entire wall!).
Finally, like PyEdit, PyClock can be run either standalone or
attached to and embedded in other GUIs that need to display the
current time. When standalone, the windows
module from the preceding chapter
(Example 10-16) is
reused here to get a window icon, title, and quit pop up for free.
To make it easy to start preconfigured clocks, a utility module
called clockStyles
provides a set
of clock configuration objects you can import, subclass to extend,
and pass to the clock constructor; Figure 11-24 shows a few of
the preconfigured clock styles and sizes in action, ticking away in
sync.
Run clockstyles.py (or
select PyClock from PyDemos, which does the same) to recreate this
timely scene on your computer. Each of these clocks uses after
events to check for system-time
rollover 10 times per second. When run as top-level windows in the
same process, all receive a timer event from the same event loop.
When started as independent programs, each has an event loop of its
own. Either way, their second hands sweep in unison each second.
All of the PyClock source code lives in one file, except for the precoded configuration style objects. If you study the code at the bottom of the file shown in Example 11-12, you’ll notice that you can either make a clock object with a configuration object passed in or specify configuration options by command-line arguments such as the following (in which case, the script simply builds a configuration object for you):
C:...PP4EGuiClock> clock.py -bg gold -sh brown -size 300
More generally, you can run this file directly to start a clock with or without arguments, import and make its objects with configuration objects to get a more custom display, or import and attach its objects to other GUIs. For instance, PyGadgets in Chapter 10 runs this file with command-line options to tailor the display.
""" ############################################################################### PyClock 2.1: a clock GUI in Python/tkinter. With both analog and digital display modes, a pop-up date label, clock face images, general resizing, etc. May be run both standalone, or embedded (attached) in other GUIs that need a clock. New in 2.0: s/m keys set seconds/minutes timer for pop-up msg; window icon. New in 2.1: updated to run under Python 3.X (2.X no longer supported) ############################################################################### """ from tkinter import * from tkinter.simpledialog import askinteger import math, time, sys ############################################################################### # Option configuration classes ############################################################################### class ClockConfig: # defaults--override in instance or subclass size = 200 # width=height bg, fg = 'beige', 'brown' # face, tick colors hh, mh, sh, cog = 'black', 'navy', 'blue', 'red' # clock hands, center picture = None # face photo file class PhotoClockConfig(ClockConfig): # sample configuration size = 320 picture = '../gifs/ora-pp.gif' bg, hh, mh = 'white', 'blue', 'orange' ############################################################################### # Digital display object ############################################################################### class DigitalDisplay(Frame): def __init__(self, parent, cfg): Frame.__init__(self, parent) self.hour = Label(self) self.mins = Label(self) self.secs = Label(self) self.ampm = Label(self) for label in self.hour, self.mins, self.secs, self.ampm: label.config(bd=4, relief=SUNKEN, bg=cfg.bg, fg=cfg.fg) label.pack(side=LEFT) # TBD: could expand, and scale font on resize def onUpdate(self, hour, mins, secs, ampm, cfg): mins = str(mins).zfill(2) # or '%02d' % x self.hour.config(text=str(hour), width=4) self.mins.config(text=str(mins), width=4) self.secs.config(text=str(secs), width=4) self.ampm.config(text=str(ampm), width=4) def onResize(self, newWidth, newHeight, cfg): pass # nothing to redraw here ############################################################################### # Analog display object ############################################################################### class AnalogDisplay(Canvas): def __init__(self, parent, cfg): Canvas.__init__(self, parent, width=cfg.size, height=cfg.size, bg=cfg.bg) self.drawClockface(cfg) self.hourHand = self.minsHand = self.secsHand = self.cog = None def drawClockface(self, cfg): # on start and resize if cfg.picture: # draw ovals, picture try: self.image = PhotoImage(file=cfg.picture) # bkground except: self.image = BitmapImage(file=cfg.picture) # save ref imgx = (cfg.size - self.image.width()) // 2 # center it imgy = (cfg.size - self.image.height()) // 2 # 3.x // div self.create_image(imgx+1, imgy+1, anchor=NW, image=self.image) originX = originY = radius = cfg.size // 2 # 3.x // div for i in range(60): x, y = self.point(i, 60, radius-6, originX, originY) self.create_rectangle(x-1, y-1, x+1, y+1, fill=cfg.fg) # mins for i in range(12): x, y = self.point(i, 12, radius-6, originX, originY) self.create_rectangle(x-3, y-3, x+3, y+3, fill=cfg.fg) # hours self.ampm = self.create_text(3, 3, anchor=NW, fill=cfg.fg) def point(self, tick, units, radius, originX, originY): angle = tick * (360.0 / units) radiansPerDegree = math.pi / 180 pointX = int( round( radius * math.sin(angle * radiansPerDegree) )) pointY = int( round( radius * math.cos(angle * radiansPerDegree) )) return (pointX + originX+1), (originY+1 - pointY) def onUpdate(self, hour, mins, secs, ampm, cfg): # on timer callback if self.cog: # redraw hands, cog self.delete(self.cog) self.delete(self.hourHand) self.delete(self.minsHand) self.delete(self.secsHand) originX = originY = radius = cfg.size // 2 # 3.x div hour = hour + (mins / 60.0) hx, hy = self.point(hour, 12, (radius * .80), originX, originY) mx, my = self.point(mins, 60, (radius * .90), originX, originY) sx, sy = self.point(secs, 60, (radius * .95), originX, originY) self.hourHand = self.create_line(originX, originY, hx, hy, width=(cfg.size * .04), arrow='last', arrowshape=(25,25,15), fill=cfg.hh) self.minsHand = self.create_line(originX, originY, mx, my, width=(cfg.size * .03), arrow='last', arrowshape=(20,20,10), fill=cfg.mh) self.secsHand = self.create_line(originX, originY, sx, sy, width=1, arrow='last', arrowshape=(5,10,5), fill=cfg.sh) cogsz = cfg.size * .01 self.cog = self.create_oval(originX-cogsz, originY+cogsz, originX+cogsz, originY-cogsz, fill=cfg.cog) self.dchars(self.ampm, 0, END) self.insert(self.ampm, END, ampm) def onResize(self, newWidth, newHeight, cfg): newSize = min(newWidth, newHeight) #print('analog onResize', cfg.size+4, newSize) if newSize != cfg.size+4: cfg.size = newSize-4 self.delete('all') self.drawClockface(cfg) # onUpdate called next ############################################################################### # Clock composite object ############################################################################### ChecksPerSec = 10 # second change timer class Clock(Frame): def __init__(self, config=ClockConfig, parent=None): Frame.__init__(self, parent) self.cfg = config self.makeWidgets(parent) # children are packed but self.labelOn = 0 # clients pack or grid me self.display = self.digitalDisplay self.lastSec = self.lastMin = −1 self.countdownSeconds = 0 self.onSwitchMode(None) self.onTimer() def makeWidgets(self, parent): self.digitalDisplay = DigitalDisplay(self, self.cfg) self.analogDisplay = AnalogDisplay(self, self.cfg) self.dateLabel = Label(self, bd=3, bg='red', fg='blue') parent.bind('<ButtonPress-1>', self.onSwitchMode) parent.bind('<ButtonPress-3>', self.onToggleLabel) parent.bind('<Configure>', self.onResize) parent.bind('<KeyPress-s>', self.onCountdownSec) parent.bind('<KeyPress-m>', self.onCountdownMin) def onSwitchMode(self, event): self.display.pack_forget() if self.display == self.analogDisplay: self.display = self.digitalDisplay else: self.display = self.analogDisplay self.display.pack(side=TOP, expand=YES, fill=BOTH) def onToggleLabel(self, event): self.labelOn += 1 if self.labelOn % 2: self.dateLabel.pack(side=BOTTOM, fill=X) else: self.dateLabel.pack_forget() self.update() def onResize(self, event): if event.widget == self.display: self.display.onResize(event.width, event.height, self.cfg) def onTimer(self): secsSinceEpoch = time.time() timeTuple = time.localtime(secsSinceEpoch) hour, min, sec = timeTuple[3:6] if sec != self.lastSec: self.lastSec = sec ampm = ((hour >= 12) and 'PM') or 'AM' # 0...23 hour = (hour % 12) or 12 # 12..11 self.display.onUpdate(hour, min, sec, ampm, self.cfg) self.dateLabel.config(text=time.ctime(secsSinceEpoch)) self.countdownSeconds -= 1 if self.countdownSeconds == 0: self.onCountdownExpire() # countdown timer self.after(1000 // ChecksPerSec, self.onTimer) # run N times per second # 3.x // trunc int div def onCountdownSec(self, event): secs = askinteger('Countdown', 'Seconds?') if secs: self.countdownSeconds = secs def onCountdownMin(self, event): secs = askinteger('Countdown', 'Minutes') if secs: self.countdownSeconds = secs * 60 def onCountdownExpire(self): # caveat: only one active, no progress indicator win = Toplevel() msg = Button(win, text='Timer Expired!', command=win.destroy) msg.config(font=('courier', 80, 'normal'), fg='white', bg='navy') msg.config(padx=10, pady=10) msg.pack(expand=YES, fill=BOTH) win.lift() # raise above siblings if sys.platform[:3] == 'win': # full screen on Windows win.state('zoomed') ############################################################################### # Standalone clocks ############################################################################### appname = 'PyClock 2.1' # use new custom Tk, Toplevel for icons, etc. from PP4E.Gui.Tools.windows import PopupWindow, MainWindow class ClockPopup(PopupWindow): def __init__(self, config=ClockConfig, name=''): PopupWindow.__init__(self, appname, name) clock = Clock(config, self) clock.pack(expand=YES, fill=BOTH) class ClockMain(MainWindow): def __init__(self, config=ClockConfig, name=''): MainWindow.__init__(self, appname, name) clock = Clock(config, self) clock.pack(expand=YES, fill=BOTH) # b/w compat: manual window borders, passed-in parent class ClockWindow(Clock): def __init__(self, config=ClockConfig, parent=None, name=''): Clock.__init__(self, config, parent) self.pack(expand=YES, fill=BOTH) title = appname if name: title = appname + ' - ' + name self.master.title(title) # master=parent or default self.master.protocol('WM_DELETE_WINDOW', self.quit) ############################################################################### # Program run ############################################################################### if __name__ == '__main__': def getOptions(config, argv): for attr in dir(ClockConfig): # fill default config obj, try: # from "-attr val" cmd args ix = argv.index('-' + attr) # will skip __x__ internals except: continue else: if ix in range(1, len(argv)-1): if type(getattr(ClockConfig, attr)) == int: setattr(config, attr, int(argv[ix+1])) else: setattr(config, attr, argv[ix+1]) #config = PhotoClockConfig() config = ClockConfig() if len(sys.argv) >= 2: getOptions(config, sys.argv) # clock.py -size n -bg 'blue'... #myclock = ClockWindow(config, Tk()) # parent is Tk root if standalone #myclock = ClockPopup(ClockConfig(), 'popup') myclock = ClockMain(config) myclock.mainloop()
And finally, Example 11-13 shows the module that is actually run from the PyDemos launcher script—it predefines a handful of clock styles and runs seven of them at once, attached to new top-level windows for a demo effect (though one clock per screen is usually enough in practice, even for me!).
# precoded clock configuration styles from clock import * from tkinter import mainloop gifdir = '../gifs/' if __name__ == '__main__': from sys import argv if len(argv) > 1: gifdir = argv[1] + '/' class PPClockBig(PhotoClockConfig): picture, bg, fg = gifdir + 'ora-pp.gif', 'navy', 'green' class PPClockSmall(ClockConfig): size = 175 picture = gifdir + 'ora-pp.gif' bg, fg, hh, mh = 'white', 'red', 'blue', 'orange' class GilliganClock(ClockConfig): size = 550 picture = gifdir + 'gilligan.gif' bg, fg, hh, mh = 'black', 'white', 'green', 'yellow' class LP4EClock(GilliganClock): size = 700 picture = gifdir + 'ora-lp4e.gif' bg = 'navy' class LP4EClockSmall(LP4EClock): size, fg = 350, 'orange' class Pyref4EClock(ClockConfig): size, picture = 400, gifdir + 'ora-pyref4e.gif' bg, fg, hh = 'black', 'gold', 'brown' class GreyClock(ClockConfig): bg, fg, hh, mh, sh = 'grey', 'black', 'black', 'black', 'white' class PinkClock(ClockConfig): bg, fg, hh, mh, sh = 'pink', 'yellow', 'purple', 'orange', 'yellow' class PythonPoweredClock(ClockConfig): bg, size, picture = 'white', 175, gifdir + 'pythonPowered.gif' if __name__ == '__main__': root = Tk() for configClass in [ ClockConfig, PPClockBig, #PPClockSmall, LP4EClockSmall, #GilliganClock, Pyref4EClock, GreyClock, PinkClock, PythonPoweredClock ]: ClockPopup(configClass, configClass.__name__) Button(root, text='Quit Clocks', command=root.quit).pack() root.mainloop()
Running this script creates the multiple clock display of Figure 11-24. Its configurations support numerous options; judging from the seven clocks on the display, though, it’s time to move on to our last example.
Finally, a bit of fun to close out this chapter. Our last example, PyToe, implements an artificially intelligent tic-tac-toe (sometimes called “naughts and crosses”) game-playing program in Python. Most readers are probably familiar with this simple game, so I won’t dwell on its details. In short, players take turns marking board positions, in an attempt to occupy an entire row, column, or diagonal. The first player to fill such a pattern wins.
In PyToe, board positions are marked with mouse clicks, and one of the players is a Python program. The game board itself is displayed with a simple tkinter GUI; by default, PyToe builds a 3 × 3 game board (the standard tic-tac-toe setup), but it can be configured to build and play an arbitrary N × N game.
When it comes time for the computer to select a move, artificial intelligence (AI) algorithms are used to score potential moves and search a tree of candidate moves and countermoves. This is a fairly simple problem as gaming programs go, and the heuristics used to pick moves are not perfect. Still, PyToe is usually smart enough to spot wins a few moves in advance of the user.
PyToe’s GUI is implemented as a frame of expandable packed labels,
with mouse-click bindings on the labels to catch user moves. The
label’s text is configured with the player’s mark after each move,
computer or user. The GuiMaker
class we coded earlier in the prior chapter (Example 10-3) is also reused
here again to add a simple menu bar at the top (but no toolbar is
drawn at the bottom, because PyToe leaves its format descriptor
empty). By default, the user’s mark is “X” and PyToe’s is “O.” Figure 11-25 shows PyToe run from
PyGadgets with its status pop-up dialog, on the verge of beating me
one of two ways.
Figure 11-26 shows PyToe’s help pop-up dialog, which lists its command-line configuration options. You can specify colors and font sizes for board labels, the player who moves first, the mark of the user (“X” or “O”), the board size (to override the 3 × 3 default), and the move selection strategy for the computer (e.g., “Minimax” performs a move tree search to spot wins and losses, and “Expert1” and “Expert2” use static scoring heuristics functions).
The AI gaming techniques used in PyToe are CPU intensive, and some computer move selection schemes take longer than others, but their speed varies mostly with the speed of your computer. Move selection delays are fractions of a second long on my machine for a 3 × 3 game board, for all “-mode” move-selection strategy options.
Figure 11-27 shows an alternative PyToe configuration (shown running its top-level script directly with no arguments), just after it beat me. Despite the scenes captured for this book, under some move selection options, I do still win once in a while. In larger boards and more complex games, PyToe’s move selection algorithms become even more useful.
PyToe is a big system that assumes some AI background knowledge and doesn’t really demonstrate anything new in terms of GUIs. Moreover, it was written for Python 2.X over a decade ago, and though ported to 3.X for this edition, some of it might be better recoded from scratch today. Partly because of that, but mostly because I have a page limit for this book, I’m going to refer you to the book’s examples distribution package for its source code instead of listing it here. Please see these two files in the examples distribution for PyToe implementation details:
A top-level wrapper script
The meat of the implementation
If you do look, though, probably the best hint I can give you is that the data structure used to represent board state is the crux of the matter. That is, if you understand the way boards are modeled, the rest of the code comes naturally.
For instance, the lists-based variant uses a list-of-lists to represent the board’s state, along with a simple dictionary of entry widgets for the GUI indexed by board coordinates. Clearing the board after a game is simply a matter of clearing the underlying data structures, as shown in this code excerpt from the examples named earlier:
def clearBoard(self): for row, col in self.label.keys(): self.board[row][col] = Empty self.label[(row, col)].config(text=' ')
Similarly, picking a move, at least in random mode, is simply
a matter of picking a nonempty slot in the board array and storing
the machine’s mark there and in the GUI (degree
is the board’s size):
def machineMove(self): row, col = self.pickMove() self.board[row][col] = self.machineMark self.label[(row, col)].config(text=self.machineMark) def pickMove(self): empties = [] for row in self.degree: for col in self.degree: if self.board[row][col] == Empty: empties.append((row, col)) return random.choice(empties)
Finally, checking for an end-of-game state boils down to inspecting rows, columns, and diagonals in the two-dimensional list-of-lists board in this scheme:
def checkDraw(self, board=None): board = board or self.board for row in board: if Empty in row: return 0 return 1 # none empty: draw or win def checkWin(self, mark, board=None): board = board or self.board for row in board: if row.count(mark) == self.degree: # check across return 1 for col in range(self.degree): for row in board: # check down if row[col] != mark: break else: return 1 for row in range(self.degree): # check diag1 col = row # row == col if board[row][col] != mark: break else: return 1 for row in range(self.degree): # check diag2 col = (self.degree-1) - row # row+col = degree-1 if board[row][col] != mark: break else: return 1 def checkFinish(self): if self.checkWin(self.userMark): outcome = "You've won!" elif self.checkWin(self.machineMark): outcome = 'I win again :-)' elif self.checkDraw(): outcome = 'Looks like a draw'
Other move-selection code mostly just performs other kinds of analysis on the board data structure or generates new board states to search a tree of moves and countermoves.
You’ll also find relatives of these files in the same directory that implements alternative search and move-scoring schemes, different board representations, and so on. For additional background on game scoring and searches in general, consult an AI text. It’s fun stuff, but it’s too specialized to cover well in this book.
This concludes the GUI section of this book, but this is not an end to the book’s GUI coverage. If you want to learn more about GUIs, be sure to see the tkinter examples that appear later in this book and are described at the start of this chapter. PyMailGUI, PyCalc, and the mostly external PyForm and PyTree provide additional GUI case studies. In the next section of this book, we’ll also learn how to build user interfaces that run in web browsers—a very different concept, but another option for interface design.
Keep in mind, too, that even if you don’t see a GUI example in this book that looks very close to one you need to program, you’ve already met all the building blocks. Constructing larger GUIs for your application is really just a matter of laying out hierarchical composites of the widgets presented in this part of the text.
For instance, a complex display might be composed as a collection of radio buttons, listboxes, scales, text fields, menus, and so on—all arranged in frames or grids to achieve the desired appearance. Pop-up top-level windows, as well as independently run GUI programs linked with Inter-Process Communication (IPC) mechanisms, such as pipes, signals, and sockets, can further supplement a complex graphical interface.
Moreover, you can implement larger GUI components as Python classes and attach or extend them anywhere you need a similar interface device; see PyEdit’s role in PyView and PyMailGUI for a prime example. With a little creativity, tkinter’s widget set and Python support a virtually unlimited number of layouts.
Beyond this book, see the tkinter documentation overview in Chapter 7, the books department at Python’s website at http://www.python.org, and the Web at large. Finally, if you catch the tkinter bug, I want to again recommend downloading and experimenting with the packages introduced in Chapter 7—especially Pmw, PIL, Tix, and ttk (Tix and ttk are a standard part of Python today). Such extensions add additional tools to the tkinter arsenal that can make your GUIs more sophisticated, with minimal coding.
[38] All of the larger examples in this book have Py at the start of their names. This is by convention in the Python world. If you shop around at http://www.python.org, you’ll find other free software that follows this pattern too: PyOpenGL (a Python interface to the OpenGL graphics library), PyGame (a Python game development kit), and many more. I’m not sure who started this pattern, but it has turned out to be a more or less subtle way to advertise programming language preferences to the rest of the open source world. Pythonistas are nothing if not PySubtle.
[39] Interestingly, Python’s own IDLE text editor in Python 3.1 suffers from two of the same bugs described here and resolved in this edition’s PyEdit—in 3.1, IDLE positions at line 2 instead of line 1 on file opens, and its external files search (similar to PyEdit’s Grep) crashes on 3.X Unicode decoding errors when scanning the Python standard library, causing IDLE to exit altogether. Insert snarky comment about the shoemaker’s children having no shoes here…
[40] Lest that make software engineers seem too doltish, I should also note that I have been called on repeatedly to teach Python programming to physicists, all of whom had mathematical training well in advance of my own, and many of whom were still happily abusing FORTRAN common blocks and go-tos. Specialization in modern society can make novices of us all.
[41] The PyDemos script of the preceding chapter, for instance, launches seven clocks that run in the same process, and all update smoothly on my (relatively slow) Windows 7 netbook laptop. They together consume a low single-digit percentage of the CPU’s bandwidth, and often less than the Task Manager.