## /ctk_extender/window/base.py
import sys
import customtkinter as ctk
from abc import ABC
from typing import Self, Any
from loguru import logger
from pathlib import Path
from PIL import Image
from EJIO.utils import Stack
from .config import WindowConfig
from .session import WindowSessionManager
from ..layout import Layout, get_layout
from ..layout.err import LayoutNotFoundError
from ..widget.err import WidgetMountError
from .err import WindowInstanceMissingError
__all__ = [
'Window',
]
[документация]
class Window(ABC):
"""
Абстрактный базовый класс окна приложения.
Управляет собственным навигационным стеком макетов и системными событиями.
"""
config: WindowConfig = WindowConfig(name="base_window")
def __init__(self, is_main: bool = False) -> None:
"""
:param is_main: Если True, окно инициализируется как главное окно приложения (ctk.CTk).
"""
self._is_main: bool = is_main
# Навигационный стек макетов для мобильного паттерна (push/pop)
self._layout_stack: Stack[Layout] = Stack()
self._ctk_window: ctk.CTk | ctk.CTkToplevel | None = None
# Инициализируем реальное окно customtkinter
if self._is_main:
self._ctk_window = ctk.CTk()
else:
self._ctk_window = ctk.CTkToplevel()
# Привязываем конфигурацию окна через абстрактный метод
current_config = getattr(self, "config", self.get_default_config())
# Если класс не переопределил config, но мы хотим, чтобы у него было имя класса:
if current_config.name == 'default_window':
current_config = current_config._replace(name=self.__class__.__name__.lower())
self._apply_config(current_config)
# Автоматически монтируем стартовый макет, если он указан в конфиге
if current_config.initial_layout is not None:
self.switch_layout(current_config.initial_layout)
# Перехватываем протокол системного закрытия (крестик окна)
self.ctk_instance.protocol("WM_DELETE_WINDOW", lambda: self._on_system_close())
@property
def name(self) -> str:
"""Возвращает уникальное имя окна."""
return getattr(self, "config", self.get_default_config()).name
@property
def ctk_instance(self) -> ctk.CTk | ctk.CTkToplevel:
"""Возвращает ссылку на реальный инстанс окна customtkinter."""
if self._ctk_window is None:
raise WindowInstanceMissingError(f"Window '{self.name}' already destroyed or was not created.")
return self._ctk_window
@property
def active_layout_canvas(self) -> Any:
"""Возвращает нативный Си-фрейм текущего активного макета, развернутого на экране."""
if not self._layout_stack.empty:
return self._layout_stack.peek().ctk_instance
return self.ctk_instance
[документация]
@classmethod
def get_default_config(cls) -> WindowConfig:
"""Возвращает базовый конфиг, если свойство класса не переопределено."""
return WindowConfig(name='default_window')
def _apply_config(self, cfg: WindowConfig) -> None:
"""Применяет параметры структуры WindowConfig к физическому окну."""
instance = self.ctk_instance
instance.title(cfg.title)
instance.update_idletasks()
# 1. Сначала проверяем, есть ли сохраненная валидная сессия для этого окна на диске
stored_geo = WindowSessionManager.get_stored_geometry(self.name)
if stored_geo is not None:
# Успешное восстановление: открываем окно ровно там, где его закрыл пользователь.
# При этом сохраняем текущие ширину и высоту из нового конфига (на случай, если они изменились в коде)
instance.geometry(f"{cfg.width}x{cfg.height}+{stored_geo['x']}+{stored_geo['y']}")
logger.info(f"Window '{self.name}' successfully restored in saved position: +{stored_geo['x']} +{stored_geo['y']}")
# 2. Если сессии нет, или разработчик явно жестко прописал координаты в коде WindowConfig:
elif cfg.x_pos is not None and cfg.y_pos is not None:
instance.geometry(f"{cfg.width}x{cfg.height}+{cfg.x_pos}+{cfg.y_pos}")
else:
# 3. Откат к дефолту: автоматическое математическое центрирование по центру активного монитора
self.center()
"""
screen_width = instance.winfo_screenwidth()
screen_height = instance.winfo_screenheight()
x = (screen_width // 2) - (cfg.width // 2)
y = (screen_height // 2) - (cfg.height // 2)
x = max(0, x)
y = max(0, y)
instance.geometry(f"{cfg.width}x{cfg.height}+{x}+{y}")
"""
instance.resizable(cfg.resizable_x, cfg.resizable_y)
instance.update()
[документация]
def center(self) -> None:
"""
Математически точно центрирует окно по центру текущего активного монитора,
на котором оно физически находится. Защищено от многомониторных сбоев.
"""
instance = self.ctk_instance
instance.update_idletasks()
# Считываем геометрию самого окна (из текущей конфигурации или рантайма)
current_config = getattr(self, "config", self.get_default_config())
win_w = current_config.width
win_h = current_config.height
# Многомониторный кроссплатформенный замер: winfo_vrootwidth возвращает
# параметры виртуального корня именно того дисплея, где сейчас находится окно
from EJIO.utils import SystemProperties
scr_w = SystemProperties.screen_width
scr_h = SystemProperties.screen_height
#scr_w = instance.winfo_vrootwidth()
#scr_h = instance.winfo_vrootheight()
# Защитный откат, если X-сервер/ОС вернули нулевой виртуальный корень
if scr_w <= 0:
scr_w = instance.winfo_screenwidth()
if scr_h <= 0:
scr_h = instance.winfo_screenheight()
# Рассчитываем координаты точки старта (центр экрана)
x = max(0, (scr_w // 2) - (win_w // 2))
y = max(0, (scr_h // 2) - (win_h // 2))
# Атомарно применяем новую геометрию
instance.geometry(f"{win_w}x{win_h}+{x}+{y}")
instance.update()
logger.debug(f"Window '{self.name}' successfully centered on current active monitor ({scr_w}x{scr_h}).")
[документация]
def switch_layout(self, layout_name: str) -> Self:
"""
Полностью очищает стек разметки и монтирует новый экран.
Старый экран полностью выгружается из памяти.
"""
# 1. Очищаем весь стек макетов
while not self._layout_stack.empty:
old_layout = self._layout_stack.pop()
old_layout.destroy()
try:
# 2. Получаем и создаем новый макет
layout_class = get_layout(self.name, layout_name)
new_layout = layout_class(self.ctk_instance)
self._layout_stack.push(new_layout)
new_layout.on_show()
except LayoutNotFoundError as e:
logger.critical(f"Missing layout: {e}. Starting application termination.")
sys.exit(1)
return self
[документация]
def push_layout(self, layout_name: str) -> Self:
"""
Усыпляет текущий активный экран и пушит новый на вершину стека (мобильная навигация).
"""
if not self._layout_stack.empty:
try:
current_active = self._layout_stack.peek()
current_active.unmount()
current_active.on_hide()
except WidgetMountError as e:
logger.error(f"Error unmounting layout: {e}.")
try:
layout_class = get_layout(self.name, layout_name)
new_layout = layout_class(self.ctk_instance)
self._layout_stack.push(new_layout)
new_layout.on_show()
except LayoutNotFoundError as e:
logger.critical(f"Missing layout: {e}. Starting application termination.")
return self
[документация]
def pop_layout(self) -> Self:
"""
Уничтожает текущий экран и пробуждает предыдущий из стека (Кнопка Назад).
"""
if self._layout_stack.size <= 1:
logger.warning(f"Layouts stack of window '{self.name}' is empty, cant go back.")
return self
# Толкаем верхний активный макет и уничтожаем его ресурсы
top_layout = self._layout_stack.pop()
top_layout.destroy()
try:
# Пробуждаем предыдущий макет, возвращая его на экран
previous_layout = self._layout_stack.peek()
previous_layout.mount()
previous_layout.on_show()
except WidgetMountError as e:
logger.error(f"Error mounting layout: {e}.")
return self
@property
def screenshot(self, output_path: Path | str | None = None) -> Image.Image:
"""
Делает скриншот текущего окна.
:param output_path: Путь для сохранения (опционально).
:return: Объект PIL Image.
"""
from PIL import ImageGrab
instance = self.ctk_instance
# Обновляем геометрию, чтобы получить актуальные координаты
instance.update_idletasks()
instance.update()
x = instance.winfo_rootx()
y = instance.winfo_rooty()
w = instance.winfo_width()
h = instance.winfo_height()
# Делаем захват указанной области экрана
# bbox принимает (left, top, right, bottom)
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
logger.info(f"Screenshot for window '{self.name}' successfully captured ({w}x{h}).")
if output_path:
screenshot.save(output_path)
logger.info(f"Screenshot saved to: {output_path}")
return screenshot
[документация]
def resize(self, width: int, height: int) -> Self:
"""Изменяет геометрические размеры окна."""
self.ctk_instance.geometry(f"{width}x{height}")
return self
[документация]
def minimize(self) -> Self:
"""Сворачивает окно в панель задач операционной системы."""
self.ctk_instance.iconify()
return self
[документация]
def maximize(self) -> Self:
"""Разворачивает окно на весь доступный экран."""
self.ctk_instance.state('zoomed')
return self
[документация]
def restore(self) -> Self:
"""Восстанавливает нормальный размер окна (из свернутого или развернутого состояния)."""
instance = self.ctk_instance
instance.deiconify() # Восстанавливает из свернутого состояния
instance.state('normal') # Восстанавливает из полноэкранного режима
return self
[документация]
def focus(self) -> Self:
"""Переводит фокус ввода на данное окно и выводит его на передний план."""
instance = self.ctk_instance
instance.focus()
instance.attributes("-topmost", True)
instance.attributes("-topmost", False)
return self
def _on_system_close(self) -> None:
"""Внутренний хук перехвата системного закрытия через крестик."""
from .controller import WindowController
# Делегируем закрытие контроллеру, чтобы убрать окно из живого реестра рантайма
WindowController.close_window(self.name)
[документация]
def destroy(self) -> None:
"""
Каскадно уничтожает стек макетов окна, удаляет окно из глобального
контроллера и закрывает физический ctk_instance.
"""
logger.debug(f"Beginning destruction of window '{self.name}'...")
# 1. Полноценно очищаем и уничтожаем все макеты в стеке
while not self._layout_stack.empty:
try:
layout = self._layout_stack.pop()
layout.destroy()
except Exception as e:
logger.error(f"Layout destruction error while closing window: {e}")
# 2. Закрываем реальное окно
if self._ctk_window is not None:
try:
self._ctk_window.destroy()
except Exception:
pass
self._ctk_window = None
logger.info(f"Window '{self.name}' successfully unloaded from app memory.")
[документация]
def mount(self) -> None:
"""
Вызывается фасадом Application сразу после успешного вывода окна на экран.
"""
# По умолчанию вызываем пользовательский хук
self.on_mounted()
[документация]
def on_mounted(self) -> None:
"""
Пользовательский хук жизненного цикла.
Переопределяется в дочерних окнах.
"""
pass
def __del__(self) -> None:
"""Деструктор окна. Защищает приложение от утечек памяти при закрытии окон."""
self.destroy()