Vroom! - A Simple DarkRoom/WriteRoom Remake in Tkinter
Fredrik Lundh | August 2007
Room Editors #
Recently, I’ve been using Jeffrey Fuller’s Dark Room editor for a lot of my writing. Dark Room is a Windows remake of the OS X application WriteRoom, and is designed, just as the original, to let you focus on the text you’re working on, instead of getting distracted by a plethora of fancy features. To quote Jeffrey,
“Dark Room is a full screen, distraction free, writing environment. Unlike standard word processors that focus on features, Dark Room is just about you and your text.”
Dark Room provides plain text editing in a fixed-pitch font, using green text on a dark background, basic editing commands, and not much else. The editor is designed to be used in full-screen mode.
While Dark Room has suited my needs quite well, the itch to create a clone of my own just had to be scratched. My BMW-obsessed 4-year old came up with a suitable name, Vroom!, and, building on Tkinter’s Text widget, I got the first version up and running in a few short programming sessions spread over two days.
A few programming sessions and a short writing session, that is, because what’s more suitable for the “maiden voyage” of a new editor than an article describing the implementation?
Using the Tkinter Text Widget #
The Tkinter UI framework is nearly as minimalistic as the Room editors, but it does come with a couple of extraordinarily powerful widgets. The Canvas widget provides structured graphics, and the Text widget provides a combined rich-text editing and presentation component, which makes it the perfect match for this project.
Using the Text widget as an editor is trivial; all you have to do is to create the widget, display it, and make sure it has keyboard focus:
from Tkinter import * editor = Text() editor.pack(fill=Y, expand=1) editor.config(font="Courier 12") editor.focus_set() mainloop()
The above creates a bare-bones editor widget, with a fixed-pitch Courier font, and basic emacs-style keyboard bindings. The widget is also set up to resize itself to match the Tkinter root window. If you run the script, you can start typing in text right away.
It doesn’t look much like the Dark Room editor, though. To get closer, you need to apply some basic styling.
Styling the Widget #
The following slightly enhanced script creates a root window widget, and then places a styled Text widget inside it. The editor now uses green text on black background, a white cursor (to make sure it’s visible on the black background), and a maximum width of 64 characters, even if the root window is made wider than that. Finally, support for undo/redo is enabled, which lets you use Control-Z to undo changes to the text, and Control-Y to reapply them.
from Tkinter import * root = Tk() root.title("Vroom!") root.config(background="black") root.wm_state("zoomed") editor = Text(root) editor.pack(fill=Y, expand=1) editor.config( borderwidth=0, font="{Lucida Sans Typewriter} 12", foreground="green", background="black", insertbackground="white", # cursor selectforeground="green", # selection selectbackground="#008000", wrap=WORD, # use word wrapping width=64, undo=True, # Tk 8.4 ) editor.focus_set() mainloop()
(Note that the undo/redo functionality requires Tk 8.4.)
Loading and Saving Text #
The standard Text widget and some nice styling is pretty much all you need to get started. However, the only way to get text into and out from this prototype is to copy the text via the clipboard, from or to some other editor (such as notepad or emacs).
Loading text into a Text widget is pretty straightforward; the following snippet shows how to delete the current contents (everything between line 1 column 0 and the END of the buffer), insert the new contents, and finally move the insertion cursor back to the beginning of the buffer:
text = open(filename).read() editor.delete(1.0, END) editor.insert(END, text) editor.mark_set(INSERT, 1.0)
And here’s the corresponding code to save the contents to a file. The Text widget has a habit of appending newlines to the end of the edit buffer, something that this code addresses by simply trimming away all trailing whitespace, and adding a single newline to the file on the way out.
f = open(filename, "w") text = editor.get(1.0, END) try: # normalize trailing whitespace f.write(text.rstrip()) f.write("\n") finally: f.close()
Towards a Production-Quality Implementation #
Now, given the styled widget and the snippets that shows how to load and save text, let’s start building a slightly more organized implementation. The first step is to create a custom widget class for the editor, to give us some place to add editor-related methods and attributes. Since the editor is a specialized Text widget, you can simply inherit from the Text widget class, and do the necessary setup in the initialization method.
from Tkinter import * class RoomEditor(Text): def __init__(self, master, **options): Text.__init__(self, master, **options) self.config( borderwidth=0, font="{Lucida Sans Typewriter} 14", foreground="green", background="black", insertbackground="white", # cursor selectforeground="green", # selection selectbackground="#008000", wrap=WORD, # use word wrapping undo=True, width=64, ) self.filename = None # current document
The editor class shown here inherits all methods from the Text class, and also adds a filename attribute to keep track of the currently loaded file. It’s a good idea to display this name in the editor window’s title bar, and you can use a property to make sure that this is done automatically.
Before you add the property itself, you need to add object to the list of parent classes; without that, Python’s property mechanism won’t work properly. You also need to put object after the Tkinter widget class, or Tkinter won’t work properly.
With this in place, you can just add a getter and a setter method, and use property to create the “virtual” attribute:
import os TITLE = "Vroom!" class RoomEditor(Text, object): ... def _getfilename(self): return self._filename def _setfilename(self, filename): self._filename = filename title = os.path.basename(filename or "(new document)") title = title + " - " + TITLE self.winfo_toplevel().title(title) filename = property(_getfilename, _setfilename)
With this in place, the actual filename is stored in the _filename attribute, and changes to filename will also be reflected in the title bar (note that the initialization function sets filename to None, so you don’t need to explicitly initialize the internal attribute; that’s done inside _setfilename when the widget is first created).
There’s one more thing that can be nicely handled with a property, and that’s the widget’s modification flag. This is automatically set whenever the editor buffer is modified, and can also be explicitly set or reset by the application. Unfortunately, the method used for this, edit_modified, appears to be broken on Python 2.5 (at least it doesn’t work properly in my installation), so you need to provide a work-around:
def edit_modified(self, value=None): # Python 2.5's implementation is broken return self.tk.call(self, "edit", "modified", value)
The tk.call method ignores None parameters, so a call to edit_modified without any argument will result in the Tk command “.widget edit modified”, which queries the current flag value, and calls with a boolean argument will result in “.widget edit modified value“, which modifies the flag. For convenience, you can wrap this behaviour in a property, and you can in fact use the same method both as the getter and the setter; in the former case, it’s called without any argument, so Tkinter will fetch the current flag value, and in the latter case, it’s called with the assigned value as the first argument, and will thus modify the flag.
modified = property(edit_modified, edit_modified)
So, with this in place, it’s time to add code to load and save the editor contents. The code snippets shown earlier can be used pretty much as they are, except that you need to update the document filename, the editor title bar, and the modification flag as well. Given the properties just added to the class, the latter is trivial. Just assign to the properties, and the corresponding setter code takes care of the rest.
def load(self, filename): text = open(filename).read() self.delete(1.0, END) self.insert(END, text) self.mark_set(INSERT, 1.0) self.modified = False self.filename = filename def save(self, filename=None): if filename is None: filename = self.filename f = open(filename, "w") s = self.get(1.0, END) try: f.write(s.rstrip()) f.write("\n") finally: f.close() self.modified = False self.filename = filename
What’s left is some straightforward script code to set everything up:
root = Tk() root.config(background="black") root.wm_state("zoomed") editor = RoomEditor(root) editor.pack(fill=Y, expand=1, pady=10) editor.focus_set() try: editor.load(sys.argv[1]) except (IndexError, IOError): pass mainloop()
Additional Keyboard Bindings #
At this point, the editor looks and feels pretty good, and you can pass in a document name on the command line and have it loaded into the editor buffer in one step. There’s still no way to save the document, though, and it would definitely be nice to have the usual set of “file menu” operations available, such as File/Open, File/Save, and File/Save As….
Adding this is of course just a small matter of programming.
I usually implement this kind of user-interface code in two separate layers; one for the actual operations, and one for the user-interface bindings. This makes it easier to test the implementation, and it also gives a lot more flexibility when implementing the actual bindings.
Let’s start with code for File/Open:
FILETYPES = [ ("Text files", "*.txt"), ("All files", "*") ] class Cancel(Exception): pass def open_as(): from tkFileDialog import askopenfilename f = askopenfilename(parent=root, filetypes=FILETYPES) if not f: raise Cancel try: editor.load(f) except IOError: from tkMessageBox import showwarning showwarning("Open", "Cannot open the file.") raise Cancel
Note the use of the global editor variable. An alternative would be to pass in the editor instance, but we’ll only be using a single RoomEditor instance in this version of the editor, so using a global variable makes the code a little bit simpler.
Also note the use of a custom exception to indicate that the operation was cancelled, and the use of local import statements to avoid loading user-interface components before they’re actually needed. (Python’s module system will of course still cache already loaded components for us, so subsequent imports are fast.)
The code for saving the document to a file is similar, but consists of three different functions; save_as() asks for a file name and saves the file under that name (File/Save As…), save() uses the current name if known (via the filename property), and falls back on save_as() for new documents (File/Save), and save_if_modified() checks if the document has been modified before calling save(). This last function should be used by operations that “destroy” the editor contents, such as loading a new file, or clearing the buffer.
def save_as(): from tkFileDialog import asksaveasfilename f = asksaveasfilename(parent=root, defaultextension=".txt") if not f: raise Cancel try: editor.save(f) except IOError: from tkMessageBox import showwarning showwarning("Save As", "Cannot save the file.") raise Cancel def save(): if editor.filename: try: editor.save(editor.filename) except IOError: from tkMessageBox import showwarning showwarning("Save", "Cannot save the file.") raise Cancel else: save_as() def save_if_modified(): if not editor.modified: return if askyesnocancel(TITLE, "Document modified. Save changes?"): save()
(It’s worth mentioning that this part took the longest to get “right”; my first implementation used a single save() function with keyword options to control the behaviour, but the logic was somewhat convoluted, the error handling was rather messy, and it just didn’t feel right. I finally replaced it with the much simpler, more verbose, but “obviously correct” set of functions shown here.)
The tkMessageBox module contains helpers for several commonly-used message styles, but a “yes/no/cancel”-style box is missing (at least as of Python 2.5). You can use the Message support class to implement our own helper:
def askyesnocancel(title=None, message=None, **options): import tkMessageBox s = tkMessageBox.Message( title=title, message=message, icon=tkMessageBox.QUESTION, type=tkMessageBox.YESNOCANCEL, **options).show() if isinstance(s, bool): return s if s == "cancel": raise Cancel return s == "yes"
This is similar to the corresponding code used by the tkMessageBox helpers, but uses a boolean or an exception to report the outcome, instead of string values.
With the core operations in place, you need to make them available from the user interface. For this version of the editor, let’s stick to keyboard shortcuts for all operations. For each shortcut, you need a dispatcher function, and one or more calls to bind to associate the function with a widget-level event.
def file_new(event=None): try: save_if_modified() editor.clear() except Cancel: pass return "break" # don't propagate events def file_open(event=None): try: save_if_modified() open_as() except Cancel: pass return "break" def file_save(event=None): try: save() except Cancel: pass return "break" def file_save_as(event=None): try: save_as() except Cancel: pass return "break" def file_quit(event=None): try: save_if_modified() except Cancel: return root.quit() editor.bind("<Control-n>", file_new) editor.bind("<Control-o>", file_open) editor.bind("<Control-s>", file_save) editor.bind("<Control-Shift-S>", file_save_as) editor.bind("<Control-q>", file_quit) root.protocol("WM_DELETE_WINDOW", file_quit) # window close button mainloop()
Note the use of the “break” return value, to keep Tkinter from passing the event on to other event handlers. The reason for this is that Tkinter’s Text widget already has behaviour defined for Control-O (insert new line) and Control-N (move to next line); by returning “break” from the local handler, the standard bindings won’t be allowed to interfere.
Also note the call to root.protocol to register a DELETE_WINDOW handler for the root window. This is done to make sure that an attempt to close the window via the window manager won’t shut down the application unexpectedly. This is also the reason that all event handlers have a default value for the event structure; it makes them easier to use in different contexts.
So now you have a core editor class, support code for basic file-menu operations, and a bunch of keyboard bindings to access them. What are you waiting for? Just fire up the editor and start typing. Start at the top, write you way through any issues, press Control-S to save the result, and you’ll find yourself with a nice little article in no time at all.
Like this one, which was written with the code I’ve included above.
Summary #
In this article, we built a simple Write Room-style editing application, using Tkinter’s Text widget, and a few kilobytes of mostly straight-forward Python code. The current version is a bit too feature-free even for an intentionally feature-limited editor, but it’s definitely useful as is, and it’s of course easy to add new features with a reasonable effort. It’s Python, after all.
And such enhancements are of course a suitable topic for a future article. Stay tuned.