Title: Make a font dialog with Python and tkinter
Sadly, tkinter doesn't have a ready-made font dialog. My post Make a custom dialog with Python and tkinter explained one way you can make a custom dialog in tkinter. This post uses most of those techniques to make a font dialog.
Building the User Interface
The following code shows the FontDialog class's constructor, which also creates the dialog's user interface. You can read this code for yourself, but I do want to talk a bit about a few of the more interesting statements so I have colored some of the code green and some blue.
Before I explain those statements, here's the dialog's constructor.
class FontDialog(tk.Toplevel):
def __init__(self, parent, font_object):
super().__init__(parent)
self.transient(parent) # Keep dialog above parent.
self.title('Select Font')
self.protocol('WM_DELETE_WINDOW', self.cancel_click)
# Assume we will cancel.
self.result = None
# Make a frame to hold the dialog's main body.
main_frame = tk.Frame(self)
main_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True,
padx=10, pady=10)
# Build the dialog's widgets.
left_frame = tk.Frame(main_frame, height=300, width=200)
left_frame.pack_propagate(False)
left_frame.pack(side=tk.LEFT)
# Font family.
label = tk.Label(left_frame, text='Font Family:')
label.pack(side=tk.TOP, anchor=tk.NW)
families = sorted(font.families())
font_families = tk.StringVar(value=families)
v_scrollbar = ttk.Scrollbar(left_frame, orient=tk.VERTICAL)
self.font_family_list = tk.Listbox(left_frame,
listvariable=font_families,
yscrollcommand=v_scrollbar.set,
exportselection=False)
v_scrollbar.config(command=self.font_family_list.yview)
v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.font_family_list.pack(side=tk.TOP, expand=True, fill=tk.BOTH)
self.font_family_list.bind('<<ListboxSelect>>', self.show_sample)
set_listbox_selection(self.font_family_list,
font_object.actual('family'))
# Size.
right_frame = tk.Frame(main_frame, height=300, width=200)
right_frame.pack(side=tk.LEFT, anchor=tk.NW)
right_frame.pack_propagate(False)
label = tk.Label(right_frame, text='Size:')
label.pack(side=tk.TOP, anchor=tk.NW)
min_size = 6
max_size = 72
sizes = [size for size in range(min_size, max_size+1)]
self.size_combo = ttk.Combobox(right_frame, width=20,
values=sizes, state='readonly')
self.size_combo.set(font_object.actual('size'))
self.size_combo.pack(side=tk.TOP, fill=tk.X)
self.size_combo.bind('<<ComboboxSelected>>', self.show_sample)
# Weight.
label = tk.Label(right_frame, text='Weight:')
label.pack(side=tk.TOP, anchor=tk.NW, pady=(10,0))
weights = ['Normal', 'Bold']
self.weight_combo = ttk.Combobox(right_frame, width=20,
values=weights, state='readonly')
self.weight_combo.set(font_object.actual('weight').title())
self.weight_combo.pack(side=tk.TOP, fill=tk.X)
self.weight_combo.bind('<<ComboboxSelected>>', self.show_sample)
# Slant.
label = tk.Label(right_frame, text='Slant:')
label.pack(side=tk.TOP, anchor=tk.NW, pady=(10,0))
slants = ['Roman', 'Italic']
self.slant_combo = ttk.Combobox(right_frame, width=20,
values=slants, state='readonly')
self.slant_combo.set(font_object.actual('slant').title())
self.slant_combo.pack(side=tk.TOP, fill=tk.X)
self.slant_combo.bind('<<ComboboxSelected>>', self.show_sample)
# Sample label.
self.sample_label = tk.Message(right_frame,
borderwidth=3, relief=tk.SUNKEN,
text='ABCDEFGHIJKLMNOPQRSTUVWXYZ',
anchor=tk.NW)
self.sample_label.pack(side=tk.TOP, anchor=tk.NW, pady=(10,0),
expand=True, fill=tk.BOTH)
self.show_sample(None)
# Make OK and Cancel buttons.
button_frame = tk.Frame(self, height=5)
button_frame.pack(side=tk.RIGHT, fill=tk.X, expand=False,
padx=10, pady=(0,10))
ok_button = tk.Button(button_frame, text='OK', width=7,
command=self.ok_click)
ok_button.pack(side=tk.LEFT)
self.bind('<Return>', lambda event: self.ok_click())
cancel_button = tk.Button(button_frame, text='Cancel', width=7,
command=self.cancel_click)
cancel_button.pack(side=tk.LEFT, padx=(10,0))
self.bind('<Escape>', lambda event: self.cancel_click())
self.font_family_list.focus_set()
# Grab events for this dialog.
self.grab_set()
Green Statements
The green statements set the initial values for the widgets. For example, the statement self.size_combo.set(font_object.actual('size')) sets the selected value for the Size combo box. It takes the font_object parameter that was passed into the dialog's constructor, calls its actual method to get the font's size, and then sets the Size combo box's value to that size.
The green statements that set the font's Weight and Slant values work similarly.
To select the font family in its listbox, the code calls the set_listbox_selection function, which I'll describe a bit later.
Blue Statements
The first piece of blue code sets the object's result value to None. Eventually we will return self.result to the calling code. (Don't worry, you'll see all that later.) We first set result to None and later we change it to something more useful if the user clicks OK.
The next piece of blue code deals with Frame widget layout and, to understand that, you need to know about the dialog's frames.
Dialog Frames
The dialog uses four Frame widgets to arrange its widgets. in the picture on the right, the frames are outlined with colored boxes.
The orange frame contains everything. We could probably do without it, but it makes it easy to keep all of the widgets 10 pixels away from the dialog's edges.
The red frame holds a label, listbox, and vertical scrollbar.
The green frame holds several widgets to set font properties plus a Message widget at the bottom that shows a sample of the current font.
The blue frame holds the OK and Cancel buttons.
Normally, a frame sizes itself to fits its contents and that works in many situations. For this dialog, however, I wanted the font family listbox and the sample Message widget to line up nicely at the bottom. To do that while allowing the frames to fit their contents, we would need to ensure that those contents had the same height. You could probably do that, but it could be tricky.
Even if you manage that feat, the Message widget resizes itself when its font changes and that messes up the nice alignment.
The solution uses the two blue pack_propagate calls, which prevent their frames from resizing. Instead of making the frames fit their contents, the code explicitly sets the frames' widths and heights, and then adjusts the contents to fill the frames. In the red frame, the vertical scrollbar has fill=tk.Y so it grows vertically to fill the red frame. The listbox has fill=tk.BOTH so it fills any remaining vertical space in the red frame and all of that frame's width.
In the green frame, the sample Message widget also has fill=tk.BOTH so it fills the remaining space in the green frame.
To summarize, instead of making those two frames size to fit their contents, the code uses right_frame.pack_propagate(False) to prevent that and then makes the contents grow to fill their frames.
Other Blue Code
The next piece of blue code, which includes 11 lines, creates the font family listbox. First, it calls font.families to get a list of font family names. It passes that into the sorted function to get a sorted list. The rest of the code that creates the listbox is mostly straightforward, but I want to mention two points. First, the listbox's constructor sets exportselection=False. That prevents the listbox from losing its current selection when focus moves off of it. I don't know why you might want it to forget its selection, but it definitely messes us up.
The second point is that the code binds the listbox so it calls the show_sample method when you select a font family.
The next blue statement is the second call to pack_propagate(False).
The remaining three blue statements bind the Size, Weight, and Slant combo boxes so they also call the show_sample method when you make a selection.
Showing Samples
When you change any of the font parameters, the following show_sample method executes.
def show_sample(self, event):
'''Set the sample label's font.'''
sample_font = self.get_font()
self.sample_label.configure(font=sample_font)
This method calls the get_font method described next to get a font that uses the selected settings. It then sets the sample widget's font to that font. The Message widget automatically redraws itself, including wrapping where it thinks it is necessary. It's generally wrong, but at least you can see a sample of the new font.
Here's the get_font method.
def get_font(self):
'''Make a font from the selected parameters.'''
font_family = get_listbox_selection(self.font_family_list)
font_size = self.size_combo.get()
font_weight = self.weight_combo.get().lower()
font_slant = self.slant_combo.get().lower()
# print(font_family, font_size, font_weight, font_slant)
return font.Font(family=font_family, size=font_size,
weight=font_weight, slant=font_slant)
This method gets the font family from its listbox, and gets the selected size, weight, and slant values from their combo boxes. Notice that the code converts the weight and slant values into lowercase. Values like Bold and Roman look better, but the Font class's constructor only understands bold and roman.
Uncomment the print statement if you want to see the selected values for debugging.
The method uses the current parameters to create a new Font object and returns the result.
Closing the Dialog
There are three ways you can close the dialog. First, you can click the Cancel button to execute the following code.
def cancel_click(self):
'''Close and reject the selections.'''
self.destroy()
This destroys the window.
The second way you can close the dialog is to use operating system methods. For example, in Windows you can click the X on the dialog's upper right corner or you can press Alt+F4. In that case, the statement self.protocol('WM_DELETE_WINDOW', self.cancel_click) in the constructor makes the dialog call its cancel_click method so again the dialog closes.
The final way to close the dialog is to click OK, which executes the following code.
def ok_click(self):
'''Close and accept the selections.'''
self.result = self.get_font()
self.cancel_click()
This method calls get_font to get the sample font and saves it in the dialog's result property. It then calls cancel_click to close the dialog as before.
There's one more piece to the FontDialog class: the following show method.
def show(self):
'''Display the dialog, wait for it to end, and return its results.'''
self.wait_window()
return self.result
This method waits until the window is destroyed and then returns self.result. If the user killed or canceled the dialog, then self.result is None. if the user clicked OK, then self.result is the selected sample font.
Getting and Setting Listbox Values
The example needs to get and set its font family listbox values. Unfortunately, tkinter's Listbox widget gets and sets items by their indices not by their values. Getting and setting the selection by value is a lot more useful, so this example includes two helper functions to do just that.
The following function sets a listbox's current selection by value.
def set_listbox_selection(listbox, target):
'''Set the list box's selection by value.'''
for i in range(listbox.size()):
if listbox.get(i).lower() == target.lower():
listbox.selection_clear(0, tk.END)
listbox.selection_set(i)
listbox.see(i) # Make sure it's visible
return True
return False
This code makes variable i loop through the items' indices. For each item index, it gets the item's value, converts it to lowercase, and checks whether the result matches the target value also converted into lowercase.
If the two values match, the function clears the listbox's current selection, selects the item, calls see to make sure the selected value is visible, and returns True.
The following function does the opposite: it returns a listbox's selected value.
def get_listbox_selection(listbox):
'''Get the listbox's current selection.'''
indices = listbox.curselection()
if indices:
return listbox.get(indices[0])
return None
This code gets the listbox's current selection, which is a tuple of selected indices. If the list is not empty, the function gets the value at the first index in the list and returns it. This assumes the listbox is set up to allow only one selection not an extended selection.
Using the Dialog
Here's how the example program selects a font.
def select_font(self):
'''Select the font.'''
# Get the current font.
font_name = self.font_button.cget('font')
font_object = font.nametofont(font_name)
fd = FontDialog(self.window, font_object)
button_font = fd.show()
if button_font is not None:
self.font_button.configure(font=button_font)
The code uses cget to get the font used by the example's Select Font button. The result is a string describing the font. It can be something complicated like a string describing the font, or it can be something simpler like TkDefaultFont or font30. In any case, it's not something terribly useful so the function calls font.nametofont to convert the description/name into a Font object.
The code then creates a FontDialog object for the font and calls its show method. As you saw earlier, that method returns None if the user canceled the dialog or the sample font.
If the result of the show method is not None, the example configures its Select Font button to use the new font.
Conclusion
It's unfortunate that tkinter doesn't include a font dialog but this one does a pretty good result. Download the example to give it a try and to see additional details. Then you can add the dialog to your tkinter toolkit.
|