Исходный код 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()