Title: Add safe document handling to the scribble program in Python and tkinter
If you open a file in an editor, make some changes, and then try to exit, the program asks if you want to save the changes. The program also asks if you want to save changes when you try to close the current document or replace it by opening a new file. All of this is important so you don't accidentally lose unsaved changes.
This program adds this kind of safe document handling to the scribble program.
The pieces are all simple but there are a lot of tightly integrated pieces.
This example lets you create, edit, open, and save files in the scribble program. It provides New, Open, Save, Save As, and Exit commands. The main purpose of the example is to coordinate all of those commands so the current drawing is always safe.
For example, if you open a file, make changes, and then try to open another file, the program says there are unsaved changes asks if you want to save them. If you click Yes, the program either saves the file with its current file name or prompts you for a file name if it has not been saved before.
The example also updates the program's title bar to display the name of the currently loaded file (if it has a name--new files have no names until they are saved) and displays an asterisk if the loaded document has unsaved changes.
Document Variables
The program uses three key variables to ensure that changes to the loaded document are not lost and to update the program's title bar: document_name, document_title, and is_modified. Here's how these values are initialized in the ScribbleDocumentsApp constructor.
# Initially there is no document name and it hasn't been modified.
self.document_name = ''
self.document_title = ''
self.is_modified = False
The program uses the following set_is_modified method to update the is_modified value whenever it should change.
def set_is_modified(self, value):
'''Mark the current document as modified or not.'''
self.is_modified = value
if self.is_modified:
self.window.title(f'scribble_documents * [{self.document_title}]')
else:
self.window.title(f'scribble_documents [{self.document_title}]')
This code updates is_modified. It then rebuilds the program's title bar text to display the program's name and the current file title, adding an asterisk if is_modified is true.
is_safe_to_close
This method takes care of updating the form's caption. Ensuring the document's safety is a bit more involved but the pieces are still fairly simple. The key is the is_safe_to_close method shown in the following code.
def is_safe_to_close(self):
'''Return True if it is safe to close the document.'''
# If the current document has no changes, it's safe.
if not self.is_modified: return True
# See if we have a current document name.
if self.document_name == '':
prompt = 'Save changes?'
else:
prompt = f'Save changes to {self.document_title}?'
# Ask if the user wants to save changes.
result = messagebox.askyesnocancel('Unsaved Changes?', prompt)
# If the user clicked No, discard the changes. Safe to close.
if result is False: return True
# If the user clicked Cancel, cancel the save. Not safe to close.
if result is None: return False
# Otherwise try to save the changes.
self.mnu_save()
# If we saved, then it's not safe to close.
return not self.is_modified
This method returns True if it is safe to discard the current drawing.
First, the code checks whether is_modified is true. If that value is false, then there are no unsaved changes so it's safe to close the current drawing and the method returns True.
If there are unsaved changes, the code asks the user whether it should save the changes. It uses a little code to display a prompt that includes the file's title if we have a file name and title. The user can click one of three buttons: Yes, No, or Cancel.
If the user clicks No, then the user does not want to save the changes. In that case, it's safe to discard the changes so the method returns True.
If the user clicks Cancel, then the user decided not to discard the current drawing so the method returns False to indicate that it is not safe to discard the changes. That cancels the user trying to start a new drawing, load a file, or close the program.
If the user clicks Yes, then we have a little more work to do. The code first calls mnu_save as if the user had used the File menu's Save command to try to save the file. That method, which you'll see shortly, tries to save the drawing with the current file name. If there is no current file name, it prompts the user for the file in which to save. The user might pick a file and the program tries to save, or the user might cancel the file selection. When all of this finishes, the file may or may not have been saved.
After the call to mnu_save returns, the is_safe_to_close function checks is_modified again to see if the save succeeded and returns true if it did and false if there are still unsaved changes.
For example, suppose the user starts a new drawing, makes some changes, and then tries to exit the program. The program asks whether it should save the changes. If the user clicks Yes, the program calls mnu_save, which displays a Save As dialog. If the user then cancels that dialog, the save doesn't happen, the drawing remains modified, is_safe_to_close returns false, and the exit is canceled.
Saving the Drawing
The following code shows how the File menu's Save command saves the current drawing.
def mnu_save(self):
'''Save the document with the current file name.'''
if self.document_name == '':
# We have no file name. Ask the user to pick one.
self.mnu_save_as()
else:
# Save with the current file name.
self.save_document(self.document_name)
If we don't have a file name for the current drawing, this code calls mnu_save_as to treat this as a Save As operation. Otherwise, if we have a file name, the code calls save_document to save the drawing with the current file name.
The following code shows the mnu_save_as method.
def mnu_save_as(self):
filetypes = [
('JSON Files', '*.json'),
('All Files', '*,*')]
filename = asksaveasfilename(filetypes=filetypes, defaultextension='.json')
if len(filename) == 0: return
self.save_document(filename)
This method displays a Save As dialog. If the user cancels without selecting a file, the method simply returns. In that case, the program's is_modified value remains unchanged so the drawing remains modified if it was before.
The following code shows the save_document method.
def save_document(self, filename):
'''Save the current document in {filename}.'''
try:
# Save the serialization.
json_data = serialize_canvas(self.canvas)
with open(filename, 'w', encoding='utf-8') as f:
f.write(json_data)
# Update the document name.
self.document_name = filename
self.document_title = os.path.basename(filename)
self.set_is_modified(False)
except Exception as e:
messagebox.showerror('Error',
f'Error saving file {filename},\n{e}')
This method tries to save the drawing in a JSON file much as earlier versions of the scribble program did. If it succeeds, if saves the name of the file where it saved the drawing. It uses os.path.basename to get the file's name without the path and stores it in document_title so the program can use it to update its title bar. Finally, the code calls set_is_modified to indicate that the current drawing is safe and to update the title bar.
New Drawings
Before you open a file or start a new drawing, the program must ensure that the current drawing is safe. The following code shows how the program starts a new drawing.
def mnu_new(self):
# Make sure it's safe to clear the document.
if not self.is_safe_to_close(): return
self.canvas.delete(tk.ALL)
self.document_name = ''
self.document_title = ''
self.set_is_modified(False)
This code calls is_safe_to_close to see if the drawing is safe. That method checks whether there are unsaved changes, asks the user whether to save them, tries to save the drawing in the current file, and displaying a Save As dialog if there is no current file name. If is_safe_to_close returns false, it's not safe to close the current drawing so mnu_new exits.
If it is safe to close the current drawing, is_safe_to_close removes any existing shapes from the program's canvas widget, clears the current file name and title, and calls set_is_modified to reset the program's title bar.
The mnu_open method works similarly except it opens a drawing file rather than starting a new one. I'll let you download the example to see that code.
Recording Changes
The last pieces of the puzzle are the places where the program indicates that the current drawing has been modified. For example, when you draw a new ellipse or star, the drawing has been modified.
When you draw a scribble, rectangle, oval, polygon, or star, the corresponding mouse up event handler calls the following code.
# Mark the document as modified.
self.set_is_modified(True)
This sets is_modified to true and updates the program's title bar. The program does this in the mouse up events because that's when the program actually draws the new shape. If the user clicks and releases the mouse without moving it, those shapes are not drawn so the current document isn't modified.
The text tool works a little differently. The program first draws the text in the mouse down event handler, so that's where the program calls set_is_modified.
Summary
As I mentioned, the pieces are fairly simple but they have some fairly complex interactions. Perhaps the best way to understand the whole system is to think about the use cases.
- Program starts. The initial drawing is unmodified.
- User draws. The current drawing is modified.
- User saves. If we don't have a current file name, the program displays a Save As dialog. The user may select a file or cancel the save.
- Open file. The current drawing must be safe.
- Exit. The current drawing must be safe.
- New drawing. The current drawing must be safe.
You may want to think through each of those use cases to see how the program handles them.
Download the example to see all of the details.
|