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.
 
    