Исходный код EJIO.ctk_extender.windows.popup.base
## /ctk_extender/windows/popup/base.py
import customtkinter as ctk
from abc import ABC, abstractmethod
from loguru import logger
from typing import Any
__all__ = [
'BasePopup',
]
[документация]
class BasePopup(ABC):
"""
Абстрактный базовый класс для всех модальных всплывающих окон (Popup).
Инкапсулирует создание CTkToplevel, центрирование, фокус и каскадное уничтожение.
"""
__slots__ = (
'_title',
'_width',
'_height',
'_ctk_window',
'_modal',
'_timeout',
'_timer_id',
'_main_container',
'_children'
)
def __init__(
self,
title: str,
width: int = 380,
height: int = 160,
modal: bool = True,
master: Any = None,
timeout: float | None = None,
) -> None:
"""
:param title: Заголовок окна.
:param width: Ширина окна в пикселях.
:param height: Высота окна в пикселях.
:param modal: Если True, блокирует родительский интерфейс через grab_set().
:param master: Родительский виджет.
:param timeout: Время автоматического закрытия, с.
"""
self._title: str = title
self._width: int = width
self._height: int = height
self._modal: bool = modal
self._timeout: float | None = timeout
self._timer_id: str | None = None
self._children: list[Any] = []
# Создаем CTkToplevel
self._ctk_window: ctk.CTkToplevel | None = ctk.CTkToplevel(master=master)
# Мгновенно скрываем окно с экрана.
self._ctk_window.withdraw()
# Первоначальная базовая конфигурация окна
instance = self.ctk_instance
instance.title(self._title)
instance.resizable(False, False)
# Задаем жесткую геометрию
instance.geometry(f"{self._width}x{self._height}")
# Протокол перехвата системного закрытия (крестик)
instance.protocol("WM_DELETE_WINDOW", self.destroy)
# Нативные виджеты CustomTkinter требуют CTkFrame вместо CTkToplevel,
self._main_container = ctk.CTkFrame(instance, fg_color="transparent", corner_radius=0)
self._main_container.pack(fill="both", expand=True)
# Фаза сборки контента (вызывается в наследниках)
self.build_content()
instance.update_idletasks()
# Центрируем окно на экране
screen_w = instance.winfo_screenwidth()
screen_h = instance.winfo_screenheight()
x = (screen_w // 2) - (self._width // 2)
y = (screen_h // 2) - (self._height // 2)
instance.geometry(f"{self._width}x{self._height}+{x}+{y}")
# Выводим полностью готовое, отрендеренное окно на экран.
instance.deiconify()
# Включаем жесткую модальную блокировку главного окна с небольшой задержкой,
# чтобы Tcl/Tk успел инициализировать дескриптор окна в ОС
if self._modal:
instance.after(20, instance.grab_set)
if self._timeout is not None and self._timeout > 0.0:
# Переводим секунды в миллисекунды для Tkinter
ms = int(self._timeout * 1000)
# Планируем безопасный вызов метода destroy() в потоке UI
self._timer_id = instance.after(ms, self._on_timeout_expire)
logger.debug(f" Popup '{self._title}' running with auto close timer: {self._timeout} sec.")
@property
def ctk_instance(self) -> ctk.CTkToplevel:
if self._ctk_window is None:
raise RuntimeError(f"Popup object already destroyed.")
return self._ctk_window
@property
def container(self) -> ctk.CTkFrame:
"""Возвращает безопасный фрейм-контейнер для упаковки дочерних виджетов."""
return self._main_container
[документация]
def add_widget[W](self, widget_cls: type[W], *args: Any, **kwargs: Any) -> W:
"""
Фабричный метод добавления виджетов внутрь попапа.
Гарантирует жесткое удержание ссылки в памяти, исключая авто-удаление.
"""
# В качестве родителя для физического CTkLabel/CTkButton всегда передаем фрейм подложки
widget_instance = widget_cls(self.container, *args, **kwargs)
self._children.append(widget_instance)
return widget_instance
[документация]
@abstractmethod
def build_content(self) -> None:
"""
Абстрактный метод сборки внутреннего наполнения диалога.
Переопределяется в дочерних классах.
:raises: NotImplementedError Переопределяется в подклассах.
"""
raise NotImplementedError
def _on_timeout_expire(self) -> None:
"""Внутренний воркер: вызывается при истечении таймера."""
logger.info(f"Waiting time of popup '{self._title}' expired. Automatic closing.")
self.destroy()
[документация]
def destroy(self) -> None:
"""Завершает работу диалога и освобождает модальный фокус."""
if self._ctk_window is not None and self._timer_id is not None:
try:
self._ctk_window.after_cancel(self._timer_id)
except Exception:
pass
self._timer_id = None
for child in self._children:
if child is not None:
try:
child.destroy()
except Exception:
pass
self._children.clear()
if self._main_container is not None:
try:
self._main_container.destroy()
except Exception:
pass
self._main_container = None # type: ignore[assignment]
if self._ctk_window is not None:
try:
if self._modal:
self._ctk_window.grab_release()
self._ctk_window.destroy()
except Exception:
pass
self._ctk_window = None
logger.debug(f"Model dialog '{self._title}' successfully unloaded from memory.")
def __del__(self) -> None:
self.destroy()