Я пытаюсь реализовать пользовательское окно подсказки «Да/Нет» с помощью tkinter. Однако я не хочу использовать окно сообщений по умолчанию, потому что мне нужны следующие две функции:
Мне удалось реализовать эти требования с помощью приведенного ниже кода, однако я получаю действительно непредсказуемое поведение при использовании виджетов в следующем смысле:
_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 в качестве ОС и имею виртуальную среду Python 3.11 с установленным customtkinter.
С помощью ответа @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.
Пока у меня нет 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 и установить флаг, так как это, вероятно, также происходит при уничтожении окна через оконный менеджер.
Другой подход — блок try и except в terminate, где вы ловите except _tkinter.TclError:. Но обратите внимание на подчеркивание, модуль не предназначен для использования, и он может измениться в будущем и может снова сломать ваше приложение, даже если это кажется маловероятным.
Вау, чувак, молниеносный ответ!! Большое спасибо за этот качественный ответ. Кажется, теперь я понимаю, откуда взялись эти ошибки. В итоге я использовал комбинацию двух подходов. Я внедрил флаг self.terminated и добавил его в свой метод terminate (который, кстати, также вызывается обработчиком протокола для WM_DELETE_WINDOW, см. метод self.on_closing. Однако я не смог удалить свой метод self.stop_after_callbacks. Без него я получаю другие уродливые сообщения об ошибках. распечатаны. Но скрипт не ломали. С try: ... except TclError: я и от них избавился.
@N.Maks Когда вы знаете ответ, вам просто нужно его напечатать, но спасибо за добрые слова. Я много играл с tkinter, так что я стал экспертом в python-tkinter, и я ценю хорошие вопросы. Рад, что смог тебе помочь. :)
Можете ли вы воспроизвести ошибки с чистым tkinter вместо customtkinter? Также self.tk.eval("after info") может ломаться customtkinter в зависимости от того, как запрограммировано customtkinter