Source code for zooui.windows.dialogwindows.modifytiledmediaobjectdialog
## ZooUI - Zooming User Interface
## Copyright (C) 2009 David Roberts <d@vidr.cc>
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <https://www.gnu.org/licenses/>.
"""Tiled media object editing dialog with image manipulation options."""
import os
import tempfile
from typing import TYPE_CHECKING, Literal
from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QPixmap, QTransform
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from zooui.converters.vipsconverter import VipsConverter
from zooui.logger import get_logger
from zooui.objects.mediaobjects.tiledmediaobject import TiledMediaObject
from zooui.tilesystem import tilemanager as TileManager
if TYPE_CHECKING:
from logging import Logger
# Type aliases
DialogResult = tuple[bool, str | None]
[docs]
class ModifyTiledMediaObjectDialog:
"""
Constructor :
ModifyTiledMediaObjectDialog(mediaobject)
Parameters :
mediaobject : TiledMediaObject
ModifyTiledMediaObjectDialog(mediaobject) --> None
Display the tiled media object with image manipulation options.
Shows the top tile of the tiled media object and provides buttons
for image manipulation (rotate, invert colors, black and white).
"""
[docs]
def __init__(self, mediaobject: TiledMediaObject) -> None:
"""
Method :
ModifyTiledMediaObjectDialog.__init__(mediaobject)
Parameters :
mediaobject : TiledMediaObject
ModifyTiledMediaObjectDialog.__init__(mediaobject) --> None
Initialize the dialog with the given mediaobject.
Loads the top tile (0,0,0) of the tiled media object.
"""
self.mediaobject: TiledMediaObject = mediaobject
self.media_id: str | None = mediaobject._media_id if mediaobject else None
self.tile_image: QImage | None = None
self.current_rotation: Literal[0, 90, 180, 270] = 0 # Track rotation angle in degrees
self.invert_colors: bool = False # Track invert colors state
self.black_and_white: bool = False # Track black and white state
self.image_label: QLabel
# Initialize logger
self.__logger: Logger = get_logger("ModifyTiledMediaObjectDialog")
# Try to load the top tile
if self.media_id:
try:
tile = TileManager.get_tile((self.media_id, 0, 0, 0))
# Access the private __image attribute (Tile private attribute)
# This is a PIL.ImageQt.ImageQt object
if hasattr(tile, "_Tile__image"):
self.tile_image = tile._Tile__image
self.__logger.debug(f"Successfully loaded tile for media_id: {self.media_id}")
except Exception as e:
self.__logger.error(f"Error loading tile: {e}")
self.tile_image = None
[docs]
def _create_button_panel(self) -> QWidget:
"""
Method :
ModifyTiledMediaObjectDialog._create_button_panel()
Parameters :
None
ModifyTiledMediaObjectDialog._create_button_panel() --> QWidget
Creates the button panel with image manipulation options.
"""
button_panel = QWidget()
layout = QVBoxLayout()
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# Create buttons for image manipulation
rotate_left_btn = QPushButton("Rotate Left")
rotate_left_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
rotate_left_btn.clicked.connect(self._on_rotate_left)
rotate_right_btn = QPushButton("Rotate Right")
rotate_right_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
rotate_right_btn.clicked.connect(self._on_rotate_right)
invert_colors_btn = QPushButton("Invert Colors")
invert_colors_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
invert_colors_btn.clicked.connect(self._on_invert_colors)
black_white_btn = QPushButton("Black and White")
black_white_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
black_white_btn.clicked.connect(self._on_black_white)
# Add buttons to layout
layout.addWidget(rotate_left_btn)
layout.addWidget(rotate_right_btn)
layout.addWidget(invert_colors_btn)
layout.addWidget(black_white_btn)
layout.addStretch()
button_panel.setLayout(layout)
return button_panel
[docs]
def _create_image_panel(self) -> QWidget:
"""
Method :
ModifyTiledMediaObjectDialog._create_image_panel()
Parameters :
None
ModifyTiledMediaObjectDialog._create_image_panel() --> QWidget
Creates the image display panel showing the top tile.
"""
image_panel = QWidget()
layout = QVBoxLayout()
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(0)
# Create label to display the tile image
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
if self.tile_image:
# Convert QImage to QPixmap and display
pixmap = QPixmap.fromImage(self.tile_image)
# Scale the image to fit nicely in the dialog
scaled_pixmap = pixmap.scaled(
400, 400, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
)
self.image_label.setPixmap(scaled_pixmap)
else:
self.image_label.setText("No image available")
self.image_label.setStyleSheet("QLabel { background-color: #ddd; padding: 20px; }")
self.image_label.setMinimumSize(200, 100)
layout.addWidget(self.image_label, alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
layout.addStretch()
image_panel.setLayout(layout)
return image_panel
[docs]
def _main_dialog(self) -> QDialog:
"""
Method :
ModifyTiledMediaObjectDialog._main_dialog()
Parameters :
None
ModifyTiledMediaObjectDialog._main_dialog() --> QDialog
Creates and configures the main dialog window.
"""
dialog = QDialog()
dialog.setWindowTitle("Tiled Media Object Options")
dialog.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
# Create main layout
main_layout = QHBoxLayout(dialog)
# Create image panel (left side)
image_panel = self._create_image_panel()
# Create button panel (right side)
button_panel = self._create_button_panel()
button_panel.setFixedWidth(200)
# Add panels to main layout
main_layout.addWidget(image_panel, stretch=1)
main_layout.addWidget(button_panel, stretch=0)
# Create Apply/Cancel buttons at the bottom
button_layout = QVBoxLayout()
button_layout.addStretch()
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Apply | QDialogButtonBox.StandardButton.Cancel, dialog
)
buttons.button(QDialogButtonBox.StandardButton.Apply).clicked.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
# Create a container for the main content and buttons
container = QWidget()
container_layout = QVBoxLayout(container)
content_widget = QWidget()
content_widget.setLayout(main_layout)
container_layout.addWidget(content_widget)
container_layout.addWidget(buttons)
# Set the container as the dialog's main widget
final_layout = QVBoxLayout(dialog)
final_layout.addWidget(container)
# Adjust dialog size to fit content
dialog.adjustSize()
return dialog
[docs]
def _run_dialog(self) -> tuple[bool, str | None]:
"""
Method :
ModifyTiledMediaObjectDialog._run_dialog()
Parameters :
None
ModifyTiledMediaObjectDialog._run_dialog() --> Tuple[bool, Optional[str]]
Runs the dialog and returns the result.
Returns (ok, media_id) where ok is True if accepted.
If rotation occurred, replaces the mediaobject in the scene.
"""
dialog = self._main_dialog()
# Run dialog and get result
if dialog.exec() == QDialog.DialogCode.Accepted:
# Check if any transformation was applied
has_transformations = self.current_rotation != 0 or self.invert_colors or self.black_and_white
if has_transformations:
self.__logger.info(
f"OK pressed - applying transformations: "
f"rotation={self.current_rotation}°, "
f"invert={self.invert_colors}, "
f"b&w={self.black_and_white}"
)
# Get the original source file
infile = self.mediaobject._TiledMediaObject__tmpfile # type: ignore[attr-defined]
if not infile or not os.path.exists(infile):
self.__logger.error(f"Source file not found: {infile}")
return False, None
# Create unique temp file for transformed output
fd, outfile = tempfile.mkstemp(".ppm")
os.close(fd)
# Use VipsConverter with all transformation parameters
self.__logger.info(f"Converting with VipsConverter: {infile} -> {outfile}")
converter = VipsConverter(
infile,
outfile,
rotation=self.current_rotation,
invert_colors=self.invert_colors,
black_and_white=self.black_and_white,
)
converter.run()
if converter.error:
self.__logger.error(f"VipsConverter failed: {converter.error}")
return False, None
# Replace the mediaobject with the transformed version
self._replace_mediaobject_with_rotated(outfile)
return True, outfile
else:
return True, self.media_id
else:
return False, None
[docs]
def _update_image_display(self) -> None:
"""
Method :
ModifyTiledMediaObjectDialog._update_image_display()
Parameters :
None
ModifyTiledMediaObjectDialog._update_image_display() --> None
Update the image display with current rotation, invert, and grayscale effects.
"""
if self.tile_image:
# Start with a copy of the original image
image = QImage(self.tile_image)
# Apply black and white (grayscale) if enabled
if self.black_and_white:
image = image.convertToFormat(QImage.Format.Format_Grayscale8)
# Convert back to RGB for further processing
image = image.convertToFormat(QImage.Format.Format_RGB32)
# Apply color inversion if enabled
if self.invert_colors:
image.invertPixels()
# Convert QImage to QPixmap
pixmap = QPixmap.fromImage(image)
# Apply rotation transformation
if self.current_rotation != 0:
transform = QTransform()
transform.rotate(self.current_rotation)
pixmap = pixmap.transformed(transform, Qt.TransformationMode.SmoothTransformation)
# Scale the image to fit nicely in the dialog
scaled_pixmap = pixmap.scaled(
400, 400, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
)
self.image_label.setPixmap(scaled_pixmap)
[docs]
def _replace_mediaobject_with_rotated(self, rotated_ppm_path: str) -> bool:
"""
Method :
ModifyTiledMediaObjectDialog._replace_mediaobject_with_rotated(rotated_ppm_path)
Parameters :
rotated_ppm_path : str
ModifyTiledMediaObjectDialog._replace_mediaobject_with_rotated(rotated_ppm_path) --> bool
Remove the current mediaobject from scene and add the rotated PPM as a new mediaobject.
Returns True if successful, False otherwise.
"""
try:
# Get the scene
scene = self.mediaobject._scene
# Store the old mediaobject's position and zoom for the new one
old_topleft = self.mediaobject.topleft
old_bottomright = self.mediaobject.bottomright
old_centre = self.mediaobject.centre
old_zoomlevel = self.mediaobject.zoomlevel
# Clear selection references before removing to prevent stale rectangle drawing
if scene.selection == self.mediaobject:
scene.selection = None
if scene.right_selection == self.mediaobject:
scene.right_selection = None
# Remove the old mediaobject from the scene
self.__logger.info("Removing old mediaobject from scene")
scene.remove(self.mediaobject)
# Create a new TiledMediaObject with the rotated PPM
self.__logger.info(f"Creating new TiledMediaObject with rotated PPM: {rotated_ppm_path}")
new_mediaobject = TiledMediaObject(rotated_ppm_path, scene, autofit=False)
new_mediaobject._TiledMediaObject__tmpfile = rotated_ppm_path # type: ignore[attr-defined]
# Set the new mediaobject to the same position and zoom as the old one
new_mediaobject.fit((old_topleft[0], old_topleft[1], old_bottomright[0], old_bottomright[1]))
new_mediaobject.centre = old_centre
new_mediaobject.zoomlevel = old_zoomlevel
# Add the new mediaobject to the scene
self.__logger.info("Adding new mediaobject to scene")
scene.add(new_mediaobject)
self.__logger.info("Successfully replaced mediaobject with rotated version")
return True
except Exception as e:
self.__logger.error(f"Error replacing mediaobject: {e}")
return False
# Button handlers
[docs]
def _on_rotate_left(self) -> None:
"""
Method :
ModifyTiledMediaObjectDialog._on_rotate_left()
Parameters :
None
ModifyTiledMediaObjectDialog._on_rotate_left() --> None
Rotate the image 90 degrees counter-clockwise (left).
Updates preview only; actual rotation applied on OK.
"""
self.current_rotation = (self.current_rotation - 90) % 360 # type: ignore[assignment]
self._update_image_display()
self.__logger.debug(f"Rotated left - current rotation: {self.current_rotation}°")
[docs]
def _on_rotate_right(self) -> None:
"""
Method :
ModifyTiledMediaObjectDialog._on_rotate_right()
Parameters :
None
ModifyTiledMediaObjectDialog._on_rotate_right() --> None
Rotate the image 90 degrees clockwise (right).
Updates preview only; actual rotation applied on OK.
"""
self.current_rotation = (self.current_rotation + 90) % 360 # type: ignore[assignment]
self._update_image_display()
self.__logger.debug(f"Rotated right - current rotation: {self.current_rotation}°")
[docs]
def _on_invert_colors(self) -> None:
"""
Method :
ModifyTiledMediaObjectDialog._on_invert_colors()
Parameters :
None
ModifyTiledMediaObjectDialog._on_invert_colors() --> None
Toggle invert colors effect.
Updates preview only; actual effect applied on OK.
"""
self.invert_colors = not self.invert_colors
self._update_image_display()
self.__logger.debug(f"Invert colors toggled: {self.invert_colors}")
[docs]
def _on_black_white(self) -> None:
"""
Method :
ModifyTiledMediaObjectDialog._on_black_white()
Parameters :
None
ModifyTiledMediaObjectDialog._on_black_white() --> None
Toggle black and white (grayscale) effect.
Updates preview only; actual effect applied on OK.
"""
self.black_and_white = not self.black_and_white
self._update_image_display()
self.__logger.debug(f"Black and white toggled: {self.black_and_white}")