What do I need
I am trying to implement a custom Yes / No prompt box with help of tkinter
. However I don't want to use the default messagebox, because I require the following two functionalites:
- a default value
- a countdown after which the widget destroys itself and takes the default value as answer
What are the unpredictable errors
I've managed to implement these requirements with the code below, however I get some really unpredictable behaviour when using the widgets in the following sense:
- Sometimes everything works as expected. When I press the buttons, the correct answer is stored, or if I let the countdown time out, the default answer is stored, or if I click the close-window it correctly applies the default value as answer.
- But then, at times when I click the buttons, I get some wierd errors
_tkinter.TclError: invalid command name ".!ctkframe2.!ctkcanvas"
(see execution log below for whole stacktrace)
I suspect it has something to do with the timer, since the errors do not always apper when the buttons are pressed. It is really driving me crazy...
example code
# util_gui_classes.py
# -*- coding: utf-8 -*-
"""
Classes which serve for gui applications.
"""
from typing import Any
import tkinter
import tkinter.messagebox
import customtkinter
# ____________________________________________________________________________________________
customtkinter.set_appearance_mode('System') # Modes: 'System' (standard), 'Dark', 'Light'
customtkinter.set_default_color_theme('blue') # Themes: 'blue' (standard), 'green', 'dark-blue'
# ____________________________________________________________________________________________
class GuiPromptYesNo(customtkinter.CTk):
"""
Creates a yes / no gui based prompt with default value and countdown functionality.
The user input will be stored in:
> instance.answer
"""
WIDTH = 500
HEIGHT = 200
def __init__(self, question: str, default_value: str = 'no', countdown_seconds: int = 0):
super().__init__()
self.title('input required')
self.geometry(f'{self.__class__.WIDTH}x{self.__class__.HEIGHT}')
self.protocol('WM_DELETE_WINDOW', self.on_closing) # call .on_closing() when app gets closed
self.resizable(False, False)
self.question = question
self.answer = None
self.default_value = default_value
self.countdown_seconds = countdown_seconds
self.remaining_seconds = countdown_seconds
# ============ create top-level-frames ============
# configure grid layout (4x1)
self.equal_weighted_grid(self, 4, 1)
self.grid_rowconfigure(0, minsize=10)
self.grid_rowconfigure(3, minsize=10)
self.frame_label = customtkinter.CTkFrame(master=self, corner_radius=10)
self.frame_label.grid(row=1, column=0)
self.frame_buttons = customtkinter.CTkFrame(master=self, corner_radius=0, fg_color=None)
self.frame_buttons.grid(row=2, column=0)
# ============ design frame_label ============
# configure grid layout (5x4)
self.equal_weighted_grid(self.frame_label, 5, 4)
self.frame_label.grid_rowconfigure(0, minsize=10)
self.frame_label.grid_rowconfigure(2, minsize=10)
self.frame_label.grid_rowconfigure(5, minsize=10)
self.label_question = customtkinter.CTkLabel(
master=self.frame_label,
text=self.question,
text_font=('Consolas',),
)
self.label_question.grid(row=1, column=0, columnspan=4, pady=5, padx=10)
self.label_default_value = customtkinter.CTkLabel(
master=self.frame_label,
text='default value: ',
text_font=('Consolas',),
)
self.label_default_value.grid(row=3, column=0, pady=5, padx=10)
self.entry_default_value = customtkinter.CTkEntry(
master=self.frame_label,
width=40,
justify='center',
placeholder_text=self.default_value,
state='disabled',
textvariable=tkinter.StringVar(value=self.default_value),
text_font=('Consolas',),
)
self.entry_default_value.grid(row=3, column=1, pady=5, padx=10)
if countdown_seconds > 0:
self.label_timer = customtkinter.CTkLabel(
master=self.frame_label,
text='timer [s]: ',
text_font=('Consolas',),
)
self.label_timer.grid(row=3, column=2, pady=5, padx=10)
self.entry_timer = customtkinter.CTkEntry(
master=self.frame_label,
width=40,
justify='center',
state='disabled',
textvariable=tkinter.StringVar(value=str(self.remaining_seconds)),
placeholder_text=str(self.remaining_seconds),
text_font=('Consolas',),
)
self.entry_timer.grid(row=3, column=3, pady=5, padx=10)
# ============ design frame_buttons ============
# configure grid layout (3x2)
self.equal_weighted_grid(self.frame_buttons, 3, 2)
self.frame_buttons.grid_rowconfigure(0, minsize=10)
self.frame_buttons.grid_rowconfigure(2, minsize=10)
self.button_yes = customtkinter.CTkButton(
master=self.frame_buttons,
text='yes',
text_font=('Consolas',),
command=lambda: self.button_event('yes'),
)
self.button_yes.grid(row=1, column=0, pady=5, padx=20)
self.button_no = customtkinter.CTkButton(
master=self.frame_buttons,
text='no',
text_font=('Consolas',),
command=lambda: self.button_event('no'),
)
self.button_no.grid(row=1, column=1, pady=5, padx=20)
if self.countdown_seconds > 0:
self.countdown()
self.attributes('-topmost', True)
self.mainloop()
# __________________________________________________________
# methods
@staticmethod
def equal_weighted_grid(obj: Any, rows: int, cols: int):
"""configures the grid to be of equal cell sizes for rows and columns."""
for row in range(rows):
obj.grid_rowconfigure(row, weight=1)
for col in range(cols):
obj.grid_columnconfigure(col, weight=1)
def button_event(self, answer):
"""Stores the user input as instance attribute `answer`."""
self.answer = answer
self.terminate()
def countdown(self):
"""Sets the timer for the question."""
if self.answer is not None:
self.terminate()
elif self.remaining_seconds < 0:
self.answer = self.default_value
self.terminate()
else:
self.entry_timer.configure(textvariable=tkinter.StringVar(value=str(self.remaining_seconds)))
self.remaining_seconds -= 1
self.after(1000, self.countdown)
def stop_after_callbacks(self):
"""Stops all after callbacks on the root."""
for after_id in self.tk.eval('after info').split():
self.after_cancel(after_id)
def on_closing(self, event=0):
"""If the user presses the window x button without providing input"""
if self.answer is None and self.default_value is not None:
self.answer = self.default_value
self.terminate()
def terminate(self):
"""Properly terminates the gui."""
# stop all .after callbacks to avoid error message "Invalid command ..." after destruction
self.stop_after_callbacks()
self.destroy()
# ____________________________________________________________________________________________
if __name__ == '__main__':
print('\n', 'do some python stuff before', '\n', sep='')
q1 = GuiPromptYesNo(question='1. do you want to proceed?', countdown_seconds=5)
print(f'>>>{q1.answer=}')
print('\n', 'do some python stuff in between', '\n', sep='')
q2 = GuiPromptYesNo(question='2. do you want to proceed?', countdown_seconds=5)
print(f'>>>{q2.answer=}')
print('\n', 'do some python stuff at the end', '\n', sep='')
# ____________________________________________________________________________________________
execution log with errors
The first three tests where successful (clicking buttons included), after that the errors appeared.
(py311) C:\Users\user\PycharmProjects\Sandbox\gui_tools>python util_guis.py
do some python stuff before
q1.answer='yes'
do some python stuff in between
q2.answer='no'
do some python stuff at the end
(py311) C:\Users\user\PycharmProjects\Sandbox\gui_tools>python util_guis.py
do some python stuff before
q1.answer='yes'
do some python stuff in between
q2.answer='yes'
do some python stuff at the end
(py311) C:\Users\user\PycharmProjects\Sandbox\gui_tools>python util_guis.py
do some python stuff before
q1.answer='no'
do some python stuff in between
q2.answer='no'
do some python stuff at the end
(py311) C:\Users\user\PycharmProjects\Sandbox\gui_tools>python util_guis.py
do some python stuff before
>>>q1.answer='yes'
do some python stuff in between
Exception in Tkinter callback
Traceback (most recent call last):
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 1948, in __call__
return self.func(*args)
^^^^^^^^^^^^^^^^
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 861, in callit
func(*args)
File "C:\Users\user\PycharmProjects\Sandbox\gui_tools\util_guis.py", line 197, in countdown
self.terminate()
File "C:\Users\user\PycharmProjects\Sandbox\gui_tools\util_guis.py", line 224, in terminate
child.destroy()
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\widgets\widget_base_class.py", line 85, in destroy
super().destroy()
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 2635, in destroy
for c in list(self.children.values()): c.destroy()
^^^^^^^^^^^
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\widgets\widget_base_class.py", line 85, in destroy
super().destroy()
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 2639, in destroy
Misc.destroy(self)
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 687, in destroy
self.tk.deletecommand(name)
_tkinter.TclError: can't delete Tcl command
>>>q2.answer='no'
do some python stuff at the end
(py311) C:\Users\user\PycharmProjects\Sandbox\gui_tools>python util_guis.py
do some python stuff before
Exception in Tkinter callback
Traceback (most recent call last):
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 1948, in __call__
return self.func(*args)
^^^^^^^^^^^^^^^^
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\widgets\ctk_button.py", line 377, in clicked
self.command()
File "C:\Users\user\PycharmProjects\Sandbox\gui_tools\util_guis.py", line 124, in <lambda>
command=lambda: self.button_event('yes'),
^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\user\PycharmProjects\Sandbox\gui_tools\util_guis.py", line 156, in button_event
self.terminate()
File "C:\Users\user\PycharmProjects\Sandbox\gui_tools\util_guis.py", line 186, in terminate
self.destroy()
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\windows\ctk_tk.py", line 108, in destroy
super().destroy()
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 2367, in destroy
for c in list(self.children.values()): c.destroy()
^^^^^^^^^^^
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\widgets\widget_base_class.py", line 85, in destroy
super().destroy()
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 2635, in destroy
for c in list(self.children.values()): c.destroy()
^^^^^^^^^^^
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\widgets\widget_base_class.py", line 85, in destroy
super().destroy()
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 2639, in destroy
Misc.destroy(self)
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 687, in destroy
self.tk.deletecommand(name)
_tkinter.TclError: can't delete Tcl command
Exception in Tkinter callback
Traceback (most recent call last):
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 1948, in __call__
return self.func(*args)
^^^^^^^^^^^^^^^^
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\widgets\widget_base_class.py", line 142, in update_dimensions_event
self.draw(no_color_updates=True) # faster drawing without color changes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\widgets\ctk_frame.py", line 80, in draw
requires_recoloring = self.draw_engine.draw_rounded_rect_with_border(self.apply_widget_scaling(self._current_width),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\draw_engine.py", line 88, in draw_rounded_rect_with_border
return self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, ())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\draw_engine.py", line 207, in __draw_rounded_rect_with_border_font_shapes
self._canvas.delete("border_parts")
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 2879, in delete
self.tk.call((self._w, 'delete') args)
_tkinter.TclError: invalid command name ".!ctkframe2.!ctkcanvas"
>>>q1.answer='yes'
do some python stuff in between
Exception in Tkinter callback
Traceback (most recent call last):
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 1948, in __call__
return self.func(*args)
^^^^^^^^^^^^^^^^
File "C:\Users\user\python\shared_venvs\py311\Lib\site-packages\customtkinter\widgets\ctk_button.py", line 377, in clicked
self.command()
super().destroy()
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 2639, in destroy
Misc.destroy(self)
File "C:\Program Files\Python311\Lib\tkinter\__init__.py", line 687, in destroy
self.tk.deletecommand(name)
_tkinter.TclError: can't delete Tcl command
>>>q2.answer='no'
do some python stuff at the end
(py311) C:\Users\user\PycharmProjects\Sandbox\gui_tools>
requirements
I am using Windows 11 as os and have a virtual python 3.11 environment with customtkinter installed.
EDIT:
With the help of @Thingamabobs answer I managed to achive the expected behaviour without getting the errors. Here is the final code:
# util_gui_classes.py
# -*- coding: utf-8 -*-
"""
Classes which serve for gui applications.
"""
from typing import Any
import tkinter
import tkinter.messagebox
import customtkinter
from _tkinter import TclError
# _______________________________________________________________________
customtkinter.set_appearance_mode('System') # Modes: 'System' (standard), 'Dark', 'Light'
customtkinter.set_default_color_theme('blue') # Themes: 'blue' (standard), 'green', 'dark-blue'
# _______________________________________________________________________
class GuiPromptYesNo(customtkinter.CTk):
"""
Creates a yes / no gui based prompt with default value and countdown functionality.
The user input will be stored in:
>>> instance.answer
"""
WIDTH = 500
HEIGHT = 200
def __init__(self, question: str, default_value: str = 'no', countdown_seconds: int = 0):
super().__init__()
self.terminated = False
self.title('input required')
self.geometry(f'{self.__class__.WIDTH}x{self.__class__.HEIGHT}')
self.protocol('WM_DELETE_WINDOW', self.on_closing) # call .on_closing() when app gets closed
self.resizable(False, False)
self.question = question
self.answer = None
self.default_value = default_value
self.countdown_seconds = countdown_seconds
self.remaining_seconds = countdown_seconds
# ============ create top-level-frames ============
# configure grid layout (4x1)
self.equal_weighted_grid(self, 4, 1)
self.grid_rowconfigure(0, minsize=10)
self.grid_rowconfigure(3, minsize=10)
self.frame_label = customtkinter.CTkFrame(master=self, corner_radius=10)
self.frame_label.grid(row=1, column=0)
self.frame_buttons = customtkinter.CTkFrame(master=self, corner_radius=0, fg_color=None)
self.frame_buttons.grid(row=2, column=0)
# ============ design frame_label ============
# configure grid layout (5x4)
self.equal_weighted_grid(self.frame_label, 5, 4)
self.frame_label.grid_rowconfigure(0, minsize=10)
self.frame_label.grid_rowconfigure(2, minsize=10)
self.frame_label.grid_rowconfigure(5, minsize=10)
self.label_question = customtkinter.CTkLabel(
master=self.frame_label,
text=self.question,
text_font=('Consolas',),
)
self.label_question.grid(row=1, column=0, columnspan=4, pady=5, padx=10)
self.label_default_value = customtkinter.CTkLabel(
master=self.frame_label,
text='default value: ',
text_font=('Consolas',),
)
self.label_default_value.grid(row=3, column=0, pady=5, padx=10)
self.entry_default_value = customtkinter.CTkEntry(
master=self.frame_label,
width=40,
justify='center',
placeholder_text=self.default_value,
state='disabled',
textvariable=tkinter.StringVar(value=self.default_value),
text_font=('Consolas',),
)
self.entry_default_value.grid(row=3, column=1, pady=5, padx=10)
if countdown_seconds > 0:
self.label_timer = customtkinter.CTkLabel(
master=self.frame_label,
text='timer [s]: ',
text_font=('Consolas',),
)
self.label_timer.grid(row=3, column=2, pady=5, padx=10)
self.entry_timer = customtkinter.CTkEntry(
master=self.frame_label,
width=40,
justify='center',
state='disabled',
textvariable=tkinter.StringVar(value=str(self.remaining_seconds)),
placeholder_text=str(self.remaining_seconds),
text_font=('Consolas',),
)
self.entry_timer.grid(row=3, column=3, pady=5, padx=10)
# ============ design frame_buttons ============
# configure grid layout (3x2)
self.equal_weighted_grid(self.frame_buttons, 3, 2)
self.frame_buttons.grid_rowconfigure(0, minsize=10)
self.frame_buttons.grid_rowconfigure(2, minsize=10)
self.button_yes = customtkinter.CTkButton(
master=self.frame_buttons,
text='yes',
text_font=('Consolas',),
command=lambda: self.button_event('yes'),
)
self.button_yes.grid(row=1, column=0, pady=5, padx=20)
self.button_no = customtkinter.CTkButton(
master=self.frame_buttons,
text='no',
text_font=('Consolas',),
command=lambda: self.button_event('no'),
)
self.button_no.grid(row=1, column=1, pady=5, padx=20)
if self.countdown_seconds > 0:
self.countdown()
self.attributes('-topmost', True)
self.mainloop()
# __________________________________________________________
# methods
@staticmethod
def equal_weighted_grid(obj: Any, rows: int, cols: int):
"""configures the grid to be of equal cell sizes for rows and columns."""
for row in range(rows):
obj.grid_rowconfigure(row, weight=1)
for col in range(cols):
obj.grid_columnconfigure(col, weight=1)
def button_event(self, answer):
"""Stores the user input as instance attribute `answer`."""
self.answer = answer
self.terminate()
def countdown(self):
"""Sets the timer for the question."""
if self.answer is not None:
self.terminate()
elif self.remaining_seconds < 0:
self.answer = self.default_value
self.terminate()
else:
self.entry_timer.configure(textvariable=tkinter.StringVar(value=str(self.remaining_seconds)))
self.remaining_seconds -= 1
self.after(1000, self.countdown)
def stop_after_callbacks(self):
"""Stops all after callbacks on the root."""
for after_id in self.tk.eval('after info').split():
self.after_cancel(after_id)
def on_closing(self, event=0):
"""If the user presses the window x button without providing input"""
if self.answer is None and self.default_value is not None:
self.answer = self.default_value
self.terminate()
def terminate(self):
"""Properly terminates the gui."""
# stop all .after callbacks to avoid error message "Invalid command ..." after destruction
self.stop_after_callbacks()
if not self.terminated:
self.terminated = True
try:
self.destroy()
except TclError:
self.destroy()
# _______________________________________________________________________
if __name__ == '__main__':
print('before')
q1 = GuiPromptYesNo(question='1. do you want to proceed?', countdown_seconds=5)
print(f'>>>{q1.answer=}')
print('between')
q2 = GuiPromptYesNo(question='2. do you want to proceed?', countdown_seconds=5)
print(f'>>>{q2.answer=}')
print('after')
# _______________________________________________________________________
BTW: the class can also be found in my package utils_nm
inside the module util_gui_classes
.
CodePudding user response:
While I don't have Ctk to give you the exact code. I can tell you exactly what is wrong and how you need to solve it.
You have self repeating function via after here:
def countdown(self):
"""Sets the timer for the question."""
if self.answer is not None:
self.terminate()
elif self.remaining_seconds < 0:
self.answer = self.default_value
self.terminate()
else:
self.entry_timer.configure(textvariable=tkinter.StringVar(value=str(self.remaining_seconds)))
self.remaining_seconds -= 1
self.after(1000, self.countdown)
The problem that you are facing is that you try to delete
the window that has already been destroyed in an after call. So depending on which event is quicker you are running into an error or not.
Why does this happen?
When ever you give an instruction regardless of what it is (with a few exceptions e.g update), it is placed in a event queue and scheduled in some sort of FIFO (first in, first out). So the oldest event gets processed. That means you can try to cancel an alarm but running the alarm before you actual cancel it.
How to solve?
There are different approaches available. The easiest and cleanest solution, in my opinion, is to set a flag and avoid trying to destroy an already destroyed Window. Something like:
def __init__(self, question: str, default_value: str = 'no', countdown_seconds: int = 0):
super().__init__()
self.terminated = False
and set it like:
def terminate(self):
"""Properly terminates the gui."""
# stop all .after callbacks to avoid error message "Invalid command ..." after destruction
#self.stop_after_callbacks() shouldn't be needed
if not self.terminated:
self.terminated = True
self.destroy()
In addition to the above proposal I suggest you to set up a protocol handler for WM_DELETE_WINDOW
and set the flag, since it probably also occur by destroying the window via the window manager.
A different approach is a try
and except
block in terminate
where you catch except _tkinter.TclError:
. But note the underscore, the module is not intended to be used and it can change in future and might break your app again, even if this seems unlikely.