Title: Prevent event cascades and easily update read-only Edit widgets in tkinter and Python
This example displays two pairs of Scale widgets. Each pair has one widget for degrees Fahrenheit and one for degrees Celsius. If you adjust one, it fires an event that makes the program adjust the other to match.
Updating the First Widgets
The upper pair of Scale widgets handles events in the straightforward way that most people would use. Each widget sets its command property to call an update method. For example, here's the code that creates the upper Fahrenheit Scale widget. (I won't bore you with all of the tkinter code, just the good parts.)
self.f_scale1 = tk.Scale(frame,
from_=min_f, to=max_f, command=self.f_changed1,
orient=tk.HORIZONTAL, showvalue=False)
This widget calls self.f_changed1 when its value changes. (The Celsius widget calls self.c_changed1.) Here's self.f_changed1.
def f_changed1(self, value):
'''The Fahrenheit scale changed.'''
print(f'{round(time.time()) % 1000}: f_changed1')
# Set self.fahrenheit1 to the selected value.
self.fahrenheit1 = self.f_scale1.get()
# Display the current value.
self.show_values1()
This code prints a message so you can see that it was called. The message includes a time stamp so you can tell which print statements are executed as a group.
Next, the code gets the widget's current value and saves it in the app's fahrenheit1 field. It then calls show_values1 to update the other widgets.
The c_changed1 method is very similar except it gets the Celsius scale's value and converts it into degrees Fahrenheit before saving it in self.fahrenheit1.
Here's the show_values1 method:
def show_values1(self):
'''Display all values.'''
print(f'{round(time.time()) % 1000}: show_values1')
# Get the current value.
degrees_f = self.fahrenheit1
degrees_c = fahrenheit_to_celsius(degrees_f)
# Display the values.
self.f_scale1.set(degrees_f)
self.f_entry1.configure(state='normal')
self.f_entry1.delete(0, tk.END)
self.f_entry1.insert(0, f'{degrees_f:.2f}')
self.f_entry1.configure(state='readonly')
self.c_scale1.set(degrees_c)
self.c_entry1.configure(state='normal')
self.c_entry1.delete(0, tk.END)
self.c_entry1.insert(0, f'{degrees_c:.2f}')
self.c_entry1.configure(state='readonly')
This code gets the self.fahrenheit1 value and converts it into degrees Celsius. Next, it sets the f_scale1 Scale widget's value.
It then sets the Entry widget's value. That's a bit trickier because this widget is read-only, so the code must first set its state to normal, then set the value (which itself is a bit awkward), and finally reset the widget's state to readonly.
The code then repeats those steps to update the Celsius Scale and Entry widgets.
This is all somewhat clumsy and we'll get to that, but for now I want to look at what events are fired.
Cascading Events
An event cascade occurs when a change to a widget makes it call some code that changes another widget. That change calls some code that changes another widget (possibly the first one), which causes a change that makes the widget execute some code, and so forth.
To see how this happens in this example, let's ignore the Edit widgets for now. In that case, here's what happens when you adjust the f_scale1 Scale widget's value.
- The widget's value has changed so it calls f_changed1.
- The f_changed1 method updates self.fahrenheit1 and calls show_values1.
- The show_values1 method updates the f_scale1 widget. The value is the same value it had when this all started and the widget is smart enough to know that it hasn't changed, so it doesn't call f_changed1 again.
- The show_values1 method updates the c_scale1 widget. That widget's value did change, so it calls c_changed1.
- The c_changed1 method converts the widget's value in degrees Celsius into degrees Fahrenheit, updates self.fahrenheit1, and calls show_values1.
- The show_values1 method runs again and updates the f_scale1 widget. At this point one of two things can happen.
- If the new self.fahrenheit1 value is the same as the previous one, the f_scale1 widget's value doesn't change so it doesn't call f_changed1. This would happen if the conversion from Fahrenheit to Celsius and back to Fahrenheit was perfect, but sometimes rounding errors make the new and old value different. In that case, the following step occurs.
- If the new and old Fahrenheit values are different due to rounding errors, the f_scale1 widget calls f_changed1 and the whole thing starts again.
- After updating f_scale1, the show_values1 method updates c_scale1. Because this call to show_values1 came from the change to c_scale1, its value hasn't changed so it doesn't raise a new event.
As if this wasn't bad enough, Step 1.A.ii.a.1.A could make the whole thing start over! Here's what the call stack looks like when that step begins.
f_changed1
show_values1
c_changed1
show_values1
If rounding errors change the f_scale1 value, the whole thing starts over and you get more method calls added to the already messy pile.
If you look at the output on the right, you'll see that the first time I adjusted the top Scale widget, the program called six methods. The second time I got lucky and the program only called two methods. The third time through caused four method calls.
In some programs, depending on the widgets involved, the cascade can become much larger or even never end making the methods recursively call each other forever.
Avoiding Cascades
Fortunately, there's a relatively simple way to avoid an event cascade. As a side effect, this method makes it easier to update read-only Edit widgets.
Use tkinter variables to update the widgets.
That's it! A widget's set method mimics a change by the user so it triggers the widget's command. Changing a variable short circuits the process and doesn't trigger the widget's command. A nice side effect is that a variable can change the value displayed by a read-only Edit widget.
Here's the code that creates the program's lower Fahrenheit Scale and Entry widgets.
self.f_scale1_var2 = tk.DoubleVar(value=self.fahrenheit2)
scale = tk.Scale(frame, variable=self.f_scale1_var2,
from_=min_f, to=max_f, command=self.f_changed2,
orient=tk.HORIZONTAL, showvalue=False)
scale.pack(side=tk.LEFT, expand=True, fill=tk.X)
self.f_entry1_var2 = tk.StringVar(value=f'{self.fahrenheit2:.2f}')
entry = tk.Entry(frame, width=7, state='readonly',
textvariable=self.f_entry1_var2,
justify=tk.RIGHT)
entry.pack(side=tk.RIGHT)
This code creates a DoubleVar to hold the Scale widget's value and then uses it to make that widget. It then performs similar steps to make an Entry widget attached to its own StringVar. The code that creates the lower Celsius widgets is similar.
Here's the f_changed2 method that the widget invokes when the user adjusts its value.
def f_changed2(self, value):
'''The Fahrenheit scale changed.'''
print(f'{round(time.time()) % 1000}: f_changed2')
# Set self.fahrenheit2 to the selected value.
self.fahrenheit2 = self.f_scale1_var2.get()
# Display the current value.
self.show_values2()
This method displays a message, updates self.fahrenheit2, and then calls show_values2. This is all pretty similar to the way the earlier widget worked.
Here's the new show_values2 method.
def show_values2(self):
'''Display all values.'''
print(f'{round(time.time()) % 1000}: show_values2')
# Get the current value.
degrees_f = self.fahrenheit2
degrees_c = fahrenheit_to_celsius(degrees_f)
# Display the values.
self.f_scale1_var2.set(degrees_f)
self.f_entry1_var2.set(f'{degrees_f:.2f}')
self.c_scale1_var2.set(degrees_c)
self.c_entry1_var2.set(f'{degrees_c:.2f}')
This code displays a message, gets the current value of self.fahrenheit2, and converts that value into degrees Celsius. It then updates the two Scale widgets and the two Entry widgets by setting their variable values.
That's all there is to it! No event cascade happens and the program doesn't need to make the Entry widgets editable before changing their values.
Conclusion
The variables provided by tkinter are often somewhat helpful, but this makes them incredibly useful! They let you update widgets without causing an event cascade and they greatly simplify updating read-only Entry widgets.
Download the example to experiment with it and to see additional details.
|