我需要什么
我试图在tkinter的帮助下实现一个自定义的“是/否”提示框。但是,我不想使用默认的messagebox,因为我需要以下两个功能:
什么是不可预测的错误?
我已经成功地用下面的代码实现了这些需求,但是当我在以下意义上使用这些小部件时,我会发现一些非常不可预测的行为:
_tkinter.TclError: invalid command name ".!ctkframe2.!ctkcanvas"错误(请参阅下面的执行日志中的整个堆栈跟踪)。我怀疑这与计时器有关,因为当按下按钮时,错误并不总是会出现。真的快把我逼疯了..。
示例代码
# 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='')
# ____________________________________________________________________________________________带有错误的执行日志
前三个测试成功的地方(包括点击按钮),然后出现错误。
(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>要求
我使用Windows 11作为操作系统,并安装了一个带有定做机的虚拟python3.11环境。
编辑:
在@Thingamabobs回答的帮助下,我成功地实现了预期的行为而没有错误。以下是最终代码:
# 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')
# _______________________________________________________________________顺便说一句:这个类也可以在我的包utils_nm中找到,它位于模块util_gui_classes中。
发布于 2022-11-18 12:03:10
而我没有Ctk给你确切的密码。我可以告诉你到底出了什么问题,你需要怎么解决。
在这里之后,您有自我重复的功能:
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)您所面临的问题是,您试图delete已在一个事后调用中已被破坏的窗口。因此,取决于哪个事件运行得更快,您是否遇到了错误。
为什么会发生这种事?
当您给出一个指令时,不管它是什么(除了一些例外,例如更新),它都被放置在一个事件队列中,并以某种形式的FIFO (先进先出)调度。所以最古老的事件就会被处理。这意味着您可以尝试取消警报,但在实际取消警报之前运行警报。
如何解决?
有不同的方法可供选择。在我看来,最简单和最干净的解决方案是设置一个标志,避免试图破坏一个已经被破坏的窗口。类似于:
def __init__(self, question: str, default_value: str = 'no', countdown_seconds: int = 0):
super().__init__()
self.terminated = False并将其设置为:
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()除了上述建议的之外,我建议您为WM_DELETE_WINDOW设置一个协议处理程序并设置标志,因为它可能也是通过窗口管理器销毁窗口来实现的。
不同的方法是在terminate中捕获except _tkinter.TclError:的try和except块。但请注意下划线,该模块不打算使用,它可能会在未来改变,并可能再次破坏您的应用程序,即使这似乎不太可能。
https://stackoverflow.com/questions/74488759
复制相似问题