Исходный код EJIO.ctk_extender.components.image

## /ctk_extender/components/image.py

import io
import customtkinter as ctk
from PIL import Image, ImageTk
from pathlib import Path
from typing import Any
from loguru import logger
from ..widget import Widget, GeometryStrategy
from ..config import AppSettings

__all__ = [
    'ImageWidget',
]


[документация] class ImageWidget(Widget): """ Высокоуровневый интерактивный компонент для работы с изображениями на базе ctk.CTkCanvas. Поддерживает загрузку из строк-путей, объектов Path и сырых байт (bytes) в памяти. """ def __init__( self, parent: Any, source: str | Path | bytes | None = None, container_size: tuple[int, int] = (300, 300), forced_size: tuple[int, int] | None = None, placeholder_text: str = "Изображение отсутствует", geometry: GeometryStrategy | None = None, **kwargs: Any ) -> None: """ :param parent: Родительский контейнер. :param source: Источник картинки: строка-путь (str), объект пути (Path) или байты в памяти (bytes). :param container_size: Фиксированный размер рамки контейнера (ширина, высота). :param forced_size: Опционально: принудительный размер самой картинки (игнорирует пропорции). :param geometry: Стратегия размещения компонента на родителе. """ self._source: str | Path | bytes | None = source self._container_width, self._container_height = container_size self._forced_size: tuple[int, int] | None = forced_size self._placeholder_text: str = placeholder_text #TODO: добавить локализацию self._kwargs: dict[str, Any] = kwargs self._zoom_level: float = 1.0 self._canvas_image_id: int | None = None self._placeholder_text_id: int | None = None self._tk_image: ImageTk.PhotoImage | None = None self._drag_data: dict[str, int] = {"x": 0, "y": 0} self._bytes_stream: io.BytesIO | None = None self._pil_image: Image.Image | None = None super().__init__(parent, geometry) self._load_and_render() self._bind_events() def _get_theme_color(self, light_color: str, dark_color: str) -> str: """Внутренний метод: вычисляет актуальный цвет на основе выбранной темы.""" current_mode = AppSettings.appearance_mode.lower() if current_mode == "system": from EJIO.utils.system.properties import SystemProperties theme_index = 1 if SystemProperties.theme == "dark" else 0 else: theme_index = 1 if current_mode == "dark" else 0 return dark_color if theme_index == 1 else light_color
[документация] def build(self) -> ctk.CTkCanvas: """Строит фиксированный по размеру холст-контейнер.""" chosen_bg = self._get_theme_color("#E5E7EB", "#1F2937") canvas = ctk.CTkCanvas( self.parent, width=self._container_width, height=self._container_height, highlightthickness=1, bg=chosen_bg, # Фоновый цвет плейсхолдера (светлый/темный), передаем чистую строку (например, "#1F2937") bd=0, ) return canvas
[документация] def update_source(self, new_source: str | Path | bytes) -> None: """Публичный метод: динамически загружает новое изображение в созданный контейнер.""" self._source = new_source self._zoom_level = 1.0 self._load_and_render()
def _load_and_render(self) -> None: """Внутренний оркестратор состояний загрузки.""" canvas = self.ctk_instance # Очищаем старые артефакты отрисовки, если они были if self._canvas_image_id is not None: canvas.delete(self._canvas_image_id) self._canvas_image_id = None if self._placeholder_text_id is not None: canvas.delete(self._placeholder_text_id) self._placeholder_text_id = None # --- СОСТОЯНИЕ «ПУСТО»: Рисуем плейсхолдер на холсте --- if self._source is None or (isinstance(self._source, str) and not self._source.strip()): placeholder_bg = self._get_theme_color("#F3F4F6", "#111827") canvas.configure(bg=placeholder_bg) self._placeholder_text_id = canvas.create_text( self._container_width // 2, self._container_height // 2, text=self._placeholder_text, font=("Arial", 12, "italic"), fill="#9CA3AF", justify="center" ) return # --- СОСТОЯНИЕ «ДАННЫЕ ЕСТЬ»: Загружаем Pillow структуру --- standard_bg = self._get_theme_color("#FFFFFF", "#1A1A1A") canvas.configure(bg=standard_bg) # Возвращаем стандартный фон try: if isinstance(self._source, bytes): if self._bytes_stream is not None: self._bytes_stream.close() self._bytes_stream = io.BytesIO(self._source) self._pil_image = Image.open(self._bytes_stream) elif isinstance(self._source, Path): self._pil_image = Image.open(str(self._source)) else: self._pil_image = Image.open(self._source) self._orig_width, self._orig_height = self._pil_image.size except Exception as e: logger.error(f"Error loading image: {e}") return # Вычисляем базовую геометрию if self._forced_size is not None: self._current_width, self._current_height = self._forced_size else: img_aspect = self._orig_width / self._orig_height container_aspect = self._container_width / self._container_height if img_aspect > container_aspect: self._current_width = self._container_width self._current_height = int(self._container_width / img_aspect) else: self._current_height = self._container_height self._current_width = int(self._container_height * img_aspect) self._base_width = self._current_width self._base_height = self._current_height self._update_image_on_canvas() def _update_image_on_canvas(self) -> None: """Изменяет размер PIL-картинки с учетом зума и проецирует её на холст.""" if self._pil_image is None: return canvas = self.ctk_instance target_w = max(10, int(self._base_width * self._zoom_level)) target_h = max(10, int(self._base_height * self._zoom_level)) resized_pil = self._pil_image.resize((target_w, target_h), Image.Resampling.LANCZOS) self._tk_image = ImageTk.PhotoImage(resized_pil) if self._canvas_image_id is None: self._canvas_image_id = canvas.create_image( self._container_width // 2, self._container_height // 2, image=self._tk_image, anchor="center" ) else: canvas.itemconfig(self._canvas_image_id, image=self._tk_image) def _bind_events(self) -> None: """Привязывает события мыши для зума и панорамирования.""" canvas = self.ctk_instance canvas.bind("<MouseWheel>", self._on_zoom) canvas.bind("<Button-4>", self._on_zoom) canvas.bind("<Button-5>", self._on_zoom) canvas.bind("<ButtonPress-1>", self._on_drag_start) canvas.bind("<B1-Motion>", self._on_drag_move) def _on_zoom(self, event: Any) -> None: if self._pil_image is None: return if event.num == 4 or (hasattr(event, 'delta') and event.delta > 0): self._zoom_level = min(5.0, self._zoom_level + 0.1) elif event.num == 5 or (hasattr(event, 'delta') and event.delta < 0): self._zoom_level = max(0.2, self._zoom_level - 0.1) self._update_image_on_canvas() def _on_drag_start(self, event: Any) -> None: self._drag_data["x"] = event.x self._drag_data["y"] = event.y def _on_drag_move(self, event: Any) -> None: if self._pil_image is None: return canvas = self.ctk_instance if self._canvas_image_id is not None: delta_x = event.x - self._drag_data["x"] delta_y = event.y - self._drag_data["y"] canvas.move(self._canvas_image_id, delta_x, delta_y) self._drag_data["x"] = event.x self._drag_data["y"] = event.y
[документация] def destroy(self) -> None: """Очищает тяжелые Pillow-ресурсы, закрывает буферы памяти и уничтожает холст.""" if self._tk_image is not None: self._tk_image = None if self._pil_image is not None: try: self._pil_image.close() except Exception: pass self._pil_image = None # type: ignore[assignment] # Освобождаем память от байтового потока if self._bytes_stream is not None: try: self._bytes_stream.close() except Exception: pass self._bytes_stream = None super().destroy()