Source code for zooui.objects.scene.qzui

## 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/>.

"""QWidget for displaying the ZUI."""

import time
from typing import Any

from PySide6 import QtCore, QtGui, QtWidgets

from zooui.logger import get_logger
from zooui.objects.mediaobjects.mediaobjectsutils.svg.utils import (
    elongate_circle,
    elongate_diagonal_arrow,
    elongate_square,
    elongate_stick,
    elongate_straight_arrow,
    elongate_triangle,
    is_circle_svg,
    is_diagonal_arrow_svg,
    is_square_svg,
    is_stick_svg,
    is_straight_arrow_svg,
    is_triangle_svg,
)
from zooui.objects.mediaobjects.svgmediaobject import SVGMediaObject
from zooui.objects.scene import scene as Scene
from zooui.tilesystem import tilemanager as TileManager


[docs] class QZUI(QtWidgets.QWidget): """ Constructor : QZUI(parent, framerate, zoom_sensitivity) Parameters : parent : Optional[QtWidgets.QWidget] framerate : int zoom_sensitivity : int QZUI(parent, framerate, zoom_sensitivity) --> None QZUI widgets that are used for rendering the ZUI. This class defines all the methods to retrieve events, Mouse, Keyboard, etc. """ #: link error variable to QtCore.Signal() error = QtCore.Signal(str) #: OCR region selected signal, emitted with the captured QImage ocr_region_selected = QtCore.Signal(QtGui.QImage) def __init__( self, parent: QtWidgets.QWidget | None = None, framerate: int = 20, zoom_sensitivity: int = 50, config: dict[str, Any] | None = None, autosave_config: dict[str, Any] | None = None, ) -> None: """ Constructor : QZUI(parent, framerate, zoom_sensitivity, config, autosave_config) Parameters : parent : Optional[QtWidgets.QWidget] framerate : int zoom_sensitivity : int config : Optional[Dict[str, Any]] autosave_config : Optional[Dict[str, Any]] QZUI(parent, framerate, zoom_sensitivity, config, autosave_config) --> None Create a new QZUI QWidget with the given `parent` widget. Initializes the widget with specified framerate and zoom sensitivity. """ QtWidgets.QWidget.__init__(self, parent) # Use provided config or create empty dict config_dict = config or {} # Merge autosave config if provided separately (backward compatibility) if autosave_config and "autosave" not in config_dict: config_dict["autosave"] = autosave_config self.__scene: Scene.Scene = Scene.new(config=config_dict) self.__mouse_right_down: bool = False self.__mouse_left_down: bool = False self.__mousepos: tuple[int, int] | None = None self.__shift_held: bool = False self.__alt_held: bool = False self.__control_held: bool = False self.__dropped_frames: int = 0 self.__draft: bool = True # Rectangle drawing state self.__drawing_rect: bool = False self.__rect_start: tuple[int, int] | None = None self.__rect_end: tuple[int, int] | None = None # OCR screenshot selection mode self.__ocr_mode: bool = False # Deferred zoom animation for when widget hasn't been sized yet self.__scene_animation_pending: bool = False # Home point: saved (origin, zoomlevel) for quick navigation self._home_point: tuple[tuple[float, float], float] | None = None self._home_point_timestamp: float = 0.0 self.__timer: QtCore.QBasicTimer = QtCore.QBasicTimer() self.__framerate: int self.zoom_sensitivity: int = zoom_sensitivity self.reduced_framerate: int = 3 self.framerate: int = framerate # type: ignore[no-redef] # Logger for arrow elongation and other operations self.__logger = get_logger("QZUI") self.setFocusPolicy(QtCore.Qt.ClickFocus) self.setMouseTracking(True)
[docs] def set_ocr_mode(self, enabled: bool) -> None: self.__ocr_mode = enabled if enabled: self.setCursor(QtCore.Qt.CursorShape.CrossCursor) else: self.unsetCursor()
[docs] def is_ocr_mode(self) -> bool: return self.__ocr_mode
[docs] def _do_ocr_capture(self, x1: int, y1: int, x2: int, y2: int) -> None: timer_active = self.__timer.isActive() if timer_active: self.__timer.stop() try: pixmap = self.grab() cropped = pixmap.copy(x1, y1, x2 - x1, y2 - y1) self.ocr_region_selected.emit(cropped.toImage()) finally: if timer_active: self.__timer.start(int(1000 / self.framerate), self)
def __zoom(self, num_steps: float) -> None: """ Method : __zoom(num_steps) Parameters : num_steps : float __zoom(num_steps) --> None Increase the z velocity of the appropriate object by an amount proportional to num_steps. """ if self.__alt_held: scale = 1.0 / 16 else: scale = 1.0 if isinstance(self.__active_object, list): for active_object in self.__active_object: active_object.centre = self.__mousepos active_object.vz += scale * num_steps else: self.__active_object.centre = self.__mousepos self.__active_object.vz += scale * num_steps def __centre(self) -> None: """ Method : __centre() Parameters : None __centre() --> None Aim the appropriate object such that the point under the cursor will move to the centre of the screen. """ if self.__mousepos is None: return self.__active_object.vx = self.__active_object.vy = 0.0 self.__active_object.aim("x", self.width() / 2 - self.__mousepos[0]) self.__active_object.aim("y", self.height() / 2 - self.__mousepos[1])
[docs] def set_home_point(self) -> None: """ Method : set_home_point() Parameters : None set_home_point() --> None Save the current view position and zoom level as the home point. """ self._home_point = (self.scene.origin, self.scene.zoomlevel) self._home_point_timestamp = time.time()
[docs] def go_to_home_point(self) -> None: """ Method : go_to_home_point() Parameters : None go_to_home_point() --> None Restore the view to the saved home point position and zoom level. """ if self._home_point is None: return origin, zoomlevel = self._home_point self.scene.zoomlevel = zoomlevel self.scene.origin = origin
[docs] def paintEvent(self, event: QtGui.QPaintEvent) -> None: """ Method : paintEvent(event) Parameters : event : QtGui.QPaintEvent paintEvent(event) --> None Method that allows you to perform custom painting on a widget. It is part of the event handling system, and you typically override it in a subclass of a QWidget (or any subclass like QLabel, QFrame, etc.) to draw graphics using the QPainter class. """ if self.framerate: self.scene.step(1.0 / self.framerate) painter = QtGui.QPainter() painter.begin(self) try: ## paint background painter.fillRect(0, 0, self.width(), self.height(), QtCore.Qt.black) errors = [] ## render scene errors = self.scene.render(painter, self.__draft) # ## show errors for mediaobject in errors: self.error.emit("Error loading %s" + str(mediaobject)) ## Check if scene needs repaint (e.g., after SVG modification) if self.scene.check_and_clear_repaint_flag(): # Schedule another repaint to show updated content self.update() ## Draw selection rectangle if currently drawing if self.__drawing_rect and self.__rect_start and self.__rect_end: color = QtCore.Qt.blue if self.__ocr_mode else QtCore.Qt.green self.scene.action_draw_rect(self.__rect_start, self.__rect_end, painter, color) ## Draw home point marker pulse if self._home_point is not None: elapsed = time.time() - self._home_point_timestamp if elapsed < 0.8: alpha = max(0.0, 1.0 - (elapsed / 0.8)) color = QtGui.QColor(0, 200, 255, int(alpha * 255)) pen = QtGui.QPen(color, 2) painter.setPen(pen) cx = self.width() / 2 cy = self.height() / 2 size = 20.0 * (1.0 + elapsed * 2) painter.drawLine(QtCore.QPointF(cx - size, cy), QtCore.QPointF(cx + size, cy)) painter.drawLine(QtCore.QPointF(cx, cy - size), QtCore.QPointF(cx, cy + size)) painter.drawEllipse(QtCore.QPointF(cx, cy), size, size) self.update() finally: painter.end() if self.__mouse_left_down: if isinstance(self.__active_object, list): for active_object in self.__active_object: active_object.vx = active_object.vy = 0.0 else: self.__active_object.vx = self.__active_object.vy = 0.0
[docs] def timerEvent(self, event: QtCore.QTimerEvent) -> None: """ Method : timerEvent(event) Parameters : event : QtCore.QTimerEvent timerEvent(event) --> None Handle timer events to update the scene rendering. """ if not self.isVisible(): return if event.timerId() == self.__timer.timerId(): if self.scene.moving: self.__dropped_frames = 0 self.__draft = True self.update() else: ## Scene or MediaObjects are moving so drop Frames if self.__dropped_frames >= self.framerate / self.reduced_framerate: self.__dropped_frames = 0 ## since the framerate is reduced, we ## can do high-quality rendering self.__draft = False self.repaint() else: ## drop current frame self.__dropped_frames += 1 else: QtWidgets.QWidget.timerEvent(self, event)
[docs] def wheelEvent(self, event: QtGui.QWheelEvent) -> None: """ Method : wheelEvent(event) Parameters : event : QtGui.QWheelEvent wheelEvent(event) --> None Handle mouse wheel events for zooming or shape elongation. Arrow elongation: Ctrl+wheel on selected arrow SVG Square elongation: Ctrl/Shift/Alt+wheel on selected square SVG 720° scroll = 2x factor change (1° = 1/360 factor change) """ # Check for modifier key+wheel with single SVG selection # Check both tracked state and event modifiers (Alt key might be intercepted by window manager) modifiers = event.modifiers() ctrl_pressed = bool(modifiers & QtCore.Qt.KeyboardModifier.ControlModifier) shift_pressed = bool(modifiers & QtCore.Qt.KeyboardModifier.ShiftModifier) alt_pressed = bool(modifiers & QtCore.Qt.KeyboardModifier.AltModifier) has_modifier = ( self.__control_held or self.__shift_held or self.__alt_held or ctrl_pressed or shift_pressed or alt_pressed ) # Debug logging self.__logger.debug( f"Wheel event - Tracked: Ctrl={self.__control_held}, Shift={self.__shift_held}, Alt={self.__alt_held}" ) self.__logger.debug( f"Wheel event - Event modifiers: Ctrl={ctrl_pressed}, Shift={shift_pressed}, Alt={alt_pressed}" ) self.__logger.debug(f"Wheel event - has_modifier={has_modifier}, selection={self.scene.selection is not None}") if ( has_modifier and self.scene.selection and not isinstance(self.scene.selection, list) and isinstance(self.scene.selection, SVGMediaObject) ): svg_obj = self.scene.selection svg_path = svg_obj._media_id # Check if it's any type of arrow SVG is_arrow = False arrow_type = None if is_straight_arrow_svg(svg_path): is_arrow = True arrow_type = "straight" elif is_diagonal_arrow_svg(svg_path): is_arrow = True arrow_type = "diagonal" if is_arrow: # Arrow elongation only works with Ctrl key # Check both tracked state and event modifiers if not (self.__control_held or ctrl_pressed): # Fall through to normal zoom if not Ctrl self.__logger.debug("Arrow elongation requires Ctrl key, falling through to normal zoom") pass else: # Calculate elongation factor from wheel delta # 720° = 2x factor, so 1° = 1/360 factor change num_degrees = event.angleDelta().y() # Sensitivity: 1/360 per degree # Positive scroll (up) elongates, negative shortens elongation_delta = num_degrees / 360.0 current_factor = 1.0 + elongation_delta # Apply minimum factor (0.2x) if current_factor < 0.2: current_factor = 0.2 self.__logger.debug("Clamped scale factor to minimum 0.2") self.__logger.debug( f"Attempting {arrow_type} arrow elongation: factor={current_factor:.3f}, degrees={num_degrees}" ) try: # Elongate arrow (returns cache hash) cache_hash = "" if arrow_type == "straight": cache_hash = elongate_straight_arrow(svg_path, current_factor) else: # diagonal cache_hash = elongate_diagonal_arrow(svg_path, current_factor) # Update SVGMediaObject with new cache hash svg_obj._media_id = cache_hash svg_obj.mark_as_modified() # Force SVG renderer to reload from cache renderer = svg_obj._SVGMediaObject__renderer # Get cache path directly from zooui.objects.mediaobjects.mediaobjectsutils.svg.svgcache.svgcache import get_svg_cache cache_path = get_svg_cache().get_cache_path(cache_hash) if renderer.load(str(cache_path)): # Update SVG dimensions from reloaded renderer size = renderer.defaultSize() svg_obj._SVGMediaObject__width = size.width() svg_obj._SVGMediaObject__height = size.height() # Clear size cache to force recalculation svg_obj._SVGMediaObject__cached_scale = None svg_obj._SVGMediaObject__cached_onscreen_size = None # Trigger repaint self.update() self.__logger.debug( f"{(arrow_type or 'Arrow').capitalize()} arrow elongated successfully, cache hash: {cache_hash}, dimensions: {size.width()}x{size.height()}" ) else: self.__logger.error(f"Failed to reload SVG renderer for cache hash: {cache_hash}") except Exception as e: # Log error but don't crash self.__logger.error(f"{(arrow_type or 'Arrow').capitalize()} arrow elongation failed: {e}") # Fall through to normal zoom return # Skip normal zoom when arrow elongation was attempted # Check if it's a square SVG elif is_square_svg(svg_path): # Calculate elongation factor from wheel delta # 720° = 2x factor, so 1° = 1/360 factor change num_degrees = event.angleDelta().y() # Sensitivity: 1/360 per degree # Positive scroll (up) elongates, negative shortens elongation_delta = num_degrees / 360.0 current_factor = 1.0 + elongation_delta # Apply minimum factor (0.2x) if current_factor < 0.2: current_factor = 0.2 self.__logger.debug("Clamped scale factor to minimum 0.2") # Use event modifiers (already calculated above) # ctrl_pressed, shift_pressed, alt_pressed are already defined scale_x = 1.0 scale_y = 1.0 scaling_mode = None if ctrl_pressed and not shift_pressed: # Ctrl only: proportional scaling (both axes) scale_x = scale_y = current_factor scaling_mode = "proportional" elif shift_pressed and not ctrl_pressed: # Shift only: X-only scaling scale_x = current_factor scale_y = 1.0 scaling_mode = "X-only" elif ctrl_pressed and shift_pressed: # Ctrl+Shift: Y-only scaling scale_x = 1.0 scale_y = current_factor scaling_mode = "Y-only (Ctrl+Shift)" # Only attempt square elongation if we have a valid modifier combination if scaling_mode: self.__logger.debug( f"Attempting square elongation: X={scale_x:.3f}, Y={scale_y:.3f}, mode={scaling_mode}, degrees={num_degrees}" ) try: # Elongate square (returns cache hash) cache_hash = elongate_square(svg_path, scale_x, scale_y) # Update SVGMediaObject with new cache hash svg_obj._media_id = cache_hash svg_obj.mark_as_modified() # Force SVG renderer to reload from cache renderer = svg_obj._SVGMediaObject__renderer # Get cache path directly from zooui.objects.mediaobjects.mediaobjectsutils.svg.svgcache.svgcache import get_svg_cache cache_path = get_svg_cache().get_cache_path(cache_hash) if renderer.load(str(cache_path)): # Update SVG dimensions from reloaded renderer size = renderer.defaultSize() svg_obj._SVGMediaObject__width = size.width() svg_obj._SVGMediaObject__height = size.height() # Clear size cache to force recalculation svg_obj._SVGMediaObject__cached_scale = None svg_obj._SVGMediaObject__cached_onscreen_size = None # Trigger repaint self.update() self.__logger.debug( f"Square elongated successfully, cache hash: {cache_hash}, dimensions: {size.width()}x{size.height()}" ) else: self.__logger.error(f"Failed to reload SVG renderer for cache hash: {cache_hash}") except Exception as e: # Log error but don't crash self.__logger.error(f"Square elongation failed: {e}") # Fall through to normal zoom return # Skip normal zoom when square elongation was attempted else: self.__logger.debug(f"No valid scaling mode for square. Ctrl={ctrl_pressed}, Shift={shift_pressed}") # Fall through to normal zoom # Check if it's a circle SVG elif is_circle_svg(svg_path): # Calculate elongation factor from wheel delta # 720° = 2x factor, so 1° = 1/360 factor change num_degrees = event.angleDelta().y() # Sensitivity: 1/360 per degree # Positive scroll (up) elongates, negative shortens elongation_delta = num_degrees / 360.0 current_factor = 1.0 + elongation_delta # Apply minimum factor (0.2x) if current_factor < 0.2: current_factor = 0.2 self.__logger.debug("Clamped scale factor to minimum 0.2") # Use event modifiers (already calculated above) # ctrl_pressed, shift_pressed, alt_pressed are already defined scale_x = 1.0 scale_y = 1.0 scaling_mode = None if ctrl_pressed and not shift_pressed: # Ctrl only: proportional scaling (both axes) scale_x = scale_y = current_factor scaling_mode = "proportional" elif shift_pressed and not ctrl_pressed: # Shift only: X-only scaling scale_x = current_factor scale_y = 1.0 scaling_mode = "X-only" elif ctrl_pressed and shift_pressed: # Ctrl+Shift: Y-only scaling scale_x = 1.0 scale_y = current_factor scaling_mode = "Y-only (Ctrl+Shift)" # Only attempt circle elongation if we have a valid modifier combination if scaling_mode: self.__logger.debug( f"Attempting circle elongation: X={scale_x:.3f}, Y={scale_y:.3f}, mode={scaling_mode}, degrees={num_degrees}" ) try: # Elongate circle (returns cache hash) cache_hash = elongate_circle(svg_path, scale_x, scale_y) # Update SVGMediaObject with new cache hash svg_obj._media_id = cache_hash svg_obj.mark_as_modified() # Force SVG renderer to reload from cache renderer = svg_obj._SVGMediaObject__renderer # Get cache path directly from zooui.objects.mediaobjects.mediaobjectsutils.svg.svgcache.svgcache import get_svg_cache cache_path = get_svg_cache().get_cache_path(cache_hash) if renderer.load(str(cache_path)): # Update SVG dimensions from reloaded renderer size = renderer.defaultSize() svg_obj._SVGMediaObject__width = size.width() svg_obj._SVGMediaObject__height = size.height() # Clear size cache to force recalculation svg_obj._SVGMediaObject__cached_scale = None svg_obj._SVGMediaObject__cached_onscreen_size = None # Trigger repaint self.update() self.__logger.debug( f"Circle elongated successfully, cache hash: {cache_hash}, dimensions: {size.width()}x{size.height()}" ) else: self.__logger.error(f"Failed to reload SVG renderer for cache hash: {cache_hash}") except Exception as e: # Log error but don't crash self.__logger.error(f"Circle elongation failed: {e}") # Fall through to normal zoom return # Skip normal zoom when circle elongation was attempted else: self.__logger.debug(f"No valid scaling mode for circle. Ctrl={ctrl_pressed}, Shift={shift_pressed}") # Fall through to normal zoom # Check if it's a triangle SVG elif is_triangle_svg(svg_path): # Calculate elongation factor from wheel delta # 720° = 2x factor, so 1° = 1/360 factor change num_degrees = event.angleDelta().y() # Sensitivity: 1/360 per degree # Positive scroll (up) elongates, negative shortens elongation_delta = num_degrees / 360.0 current_factor = 1.0 + elongation_delta # Apply minimum factor (0.2x) if current_factor < 0.2: current_factor = 0.2 self.__logger.debug("Clamped scale factor to minimum 0.2") # Use event modifiers (already calculated above) # ctrl_pressed, shift_pressed, alt_pressed are already defined scale_x = 1.0 scale_y = 1.0 scaling_mode = None if ctrl_pressed and not shift_pressed: # Ctrl only: proportional scaling (both axes) scale_x = scale_y = current_factor scaling_mode = "proportional" elif shift_pressed and not ctrl_pressed: # Shift only: X-only scaling scale_x = current_factor scale_y = 1.0 scaling_mode = "X-only" elif ctrl_pressed and shift_pressed: # Ctrl+Shift: Y-only scaling scale_x = 1.0 scale_y = current_factor scaling_mode = "Y-only (Ctrl+Shift)" # Only attempt triangle elongation if we have a valid modifier combination if scaling_mode: self.__logger.debug( f"Attempting triangle elongation: X={scale_x:.3f}, Y={scale_y:.3f}, mode={scaling_mode}, degrees={num_degrees}" ) try: # Elongate triangle (returns cache hash) cache_hash = elongate_triangle(svg_path, scale_x, scale_y) # Update SVGMediaObject with new cache hash svg_obj._media_id = cache_hash svg_obj.mark_as_modified() # Force SVG renderer to reload from cache renderer = svg_obj._SVGMediaObject__renderer # Get cache path directly from zooui.objects.mediaobjects.mediaobjectsutils.svg.svgcache.svgcache import get_svg_cache cache_path = get_svg_cache().get_cache_path(cache_hash) if renderer.load(str(cache_path)): # Update SVG dimensions from reloaded renderer size = renderer.defaultSize() svg_obj._SVGMediaObject__width = size.width() svg_obj._SVGMediaObject__height = size.height() # Clear size cache to force recalculation svg_obj._SVGMediaObject__cached_scale = None svg_obj._SVGMediaObject__cached_onscreen_size = None # Trigger repaint self.update() self.__logger.debug( f"Triangle elongated successfully, cache hash: {cache_hash}, dimensions: {size.width()}x{size.height()}" ) else: self.__logger.error(f"Failed to reload SVG renderer for cache hash: {cache_hash}") except Exception as e: # Log error but don't crash self.__logger.error(f"Triangle elongation failed: {e}") # Fall through to normal zoom return # Skip normal zoom when triangle elongation was attempted else: self.__logger.debug( f"No valid scaling mode for triangle. Ctrl={ctrl_pressed}, Shift={shift_pressed}" ) # Fall through to normal zoom # Check if it's a stick SVG elif is_stick_svg(svg_path): # Calculate elongation factor from wheel delta # 720° = 2x factor, so 1° = 1/360 factor change num_degrees = event.angleDelta().y() # Sensitivity: 1/360 per degree # Positive scroll (up) elongates, negative shortens elongation_delta = num_degrees / 360.0 current_factor = 1.0 + elongation_delta # Apply minimum factor (0.2x) if current_factor < 0.2: current_factor = 0.2 self.__logger.debug("Clamped scale factor to minimum 0.2") # For sticks, we only need Ctrl modifier (like arrows) # No need for X/Y scaling modes since sticks are 1D if ctrl_pressed: self.__logger.debug( f"Attempting stick elongation: factor={current_factor:.3f}, degrees={num_degrees}" ) try: # Elongate stick (returns cache hash) cache_hash = elongate_stick(svg_path, current_factor) # Update SVGMediaObject with new cache hash svg_obj._media_id = cache_hash svg_obj.mark_as_modified() # Force SVG renderer to reload from cache renderer = svg_obj._SVGMediaObject__renderer # Get cache path directly from zooui.objects.mediaobjects.mediaobjectsutils.svg.svgcache.svgcache import get_svg_cache cache_path = get_svg_cache().get_cache_path(cache_hash) if renderer.load(str(cache_path)): # Update SVG dimensions from reloaded renderer size = renderer.defaultSize() svg_obj._SVGMediaObject__width = size.width() svg_obj._SVGMediaObject__height = size.height() # Clear size cache to force recalculation svg_obj._SVGMediaObject__cached_scale = None svg_obj._SVGMediaObject__cached_onscreen_size = None # Trigger repaint self.update() self.__logger.debug( f"Stick elongated successfully, cache hash: {cache_hash}, dimensions: {size.width()}x{size.height()}" ) else: self.__logger.error(f"Failed to reload SVG renderer for cache hash: {cache_hash}") except Exception as e: # Log error but don't crash self.__logger.error(f"Stick elongation failed: {e}") # Fall through to normal zoom return # Skip normal zoom when stick elongation was attempted else: self.__logger.debug(f"No Ctrl modifier for stick elongation. Ctrl={ctrl_pressed}") # Fall through to normal zoom # Normal zoom behavior num_degrees = event.angleDelta().y() num_steps = round(num_degrees / self.zoom_sensitivity, 3) self.__mousepos = (int(event.position().x()), int(event.position().y())) self.__zoom(num_steps)
[docs] def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: """ Method : mousePressEvent(event) Parameters : event : QtGui.QMouseEvent mousePressEvent(event) --> None Handle mouse press events for object selection. """ if event.button() == QtCore.Qt.LeftButton: self.__mouse_left_down = True self.__mousepos = (int(event.position().x()), int(event.position().y())) if self.__ocr_mode: self.__drawing_rect = True self.__rect_start = self.__mousepos self.__rect_end = self.__mousepos return if self.__control_held: # Start rectangle drawing self.__drawing_rect = True self.__rect_start = self.__mousepos self.__rect_end = self.__mousepos # Don't change selection when drawing rectangle elif not self.__shift_held: ## shift-click won't change the selection # Only change selection if clicking on a non-selected object clicked_object = self.scene.get(self.__mousepos) # Check if we're clicking on an already-selected object if isinstance(self.scene.selection, list): # Multiple selection - check if clicked object is in the list if clicked_object in self.scene.selection: # Don't change selection, allow dragging pass elif clicked_object is None: # Clicking on empty space - keep multi-selection for dragging pass else: # Clicking on a different object - select just that object self.scene.selection = clicked_object elif self.scene.selection is not None and clicked_object == self.scene.selection: # Don't change selection, allow dragging pass elif clicked_object is None and self.scene.selection is not None: # Clicking on empty space with single selection - keep selection for dragging pass else: self.scene.selection = clicked_object else: self.__drawing_rect = False self.__rect_start = None self.__rect_end = None if event.button() == QtCore.Qt.RightButton: self.__mouse_right_down = True self.__mousepos = (int(event.position().x()), int(event.position().y())) if not self.__shift_held: ## shift-click won't change the selection self.scene.right_selection = self.scene.get(self.__mousepos)
[docs] def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: """ Method : mouseMoveEvent(event) Parameters : event : QtGui.QMouseEvent mouseMoveEvent(event) --> None Handle mouse move events for dragging objects. """ if (event.buttons() & QtCore.Qt.LeftButton) and self.__mouse_left_down: if self.__mousepos is None: return if self.__drawing_rect: # Update rectangle end position for drawing self.__rect_end = (int(event.position().x()), int(event.position().y())) # Trigger repaint to show updated rectangle self.update() else: # Original object dragging logic mx = int(event.position().x()) - self.__mousepos[0] my = int(event.position().y()) - self.__mousepos[1] t = 1.0 / self.framerate if isinstance(self.__active_object, list): for active_object in self.__active_object: active_object.aim("y", my, t) active_object.aim("x", mx, t) else: self.__active_object.aim("y", my, t) self.__active_object.aim("x", mx, t) self.__mousepos = (int(event.position().x()), int(event.position().y()))
[docs] def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: """ Method : mouseReleaseEvent(event) Parameters : event : QtGui.QMouseEvent mouseReleaseEvent(event) --> None Handle mouse release events. """ if event.button() == QtCore.Qt.LeftButton and self.__mouse_left_down: self.__mouse_left_down = False if self.__ocr_mode: self.__drawing_rect = False if self.__rect_start and self.__rect_end: x1 = min(self.__rect_start[0], self.__rect_end[0]) y1 = min(self.__rect_start[1], self.__rect_end[1]) x2 = max(self.__rect_start[0], self.__rect_end[0]) y2 = max(self.__rect_start[1], self.__rect_end[1]) if x1 != x2 and y1 != y2: QtCore.QTimer.singleShot( 0, lambda x1=x1, y1=y1, x2=x2, y2=y2: self._do_ocr_capture(x1, y1, x2, y2) ) self.__rect_start = None self.__rect_end = None self.set_ocr_mode(False) self.update() return if self.__drawing_rect: # Finish rectangle drawing self.__drawing_rect = False # If we have valid rectangle coordinates, select objects within it if self.__rect_start and self.__rect_end: # Get objects within the rectangle selected_objects = self.scene.get(self.__rect_start, self.__rect_end) if selected_objects: self.scene.selection = selected_objects # Clear rectangle coordinates self.__rect_start = None self.__rect_end = None # Trigger repaint to remove rectangle self.update()
[docs] def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: """ Method : keyPressEvent(event) Parameters : event : QtGui.QKeyEvent keyPressEvent(event) --> None Handle key press events for navigation and control. """ if self.__alt_held: move_amount = 16 else: move_amount = 16 key = event.key() modifiers = event.modifiers() if key == QtCore.Qt.Key_Escape: if self.__ocr_mode: self.set_ocr_mode(False) self.update() else: self.scene.selection = None elif key == QtCore.Qt.Key_PageUp: self.__zoom(1.0) elif key == QtCore.Qt.Key_PageDown: self.__zoom(-1.0) elif key in ( QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, QtCore.Qt.Key_G, ) and (modifiers & QtCore.Qt.ControlModifier): self._dispatch_pdf_page_nav(key, modifiers) elif key == QtCore.Qt.Key_Up: self.__active_object.aim("y", -move_amount) elif key == QtCore.Qt.Key_Down: self.__active_object.aim("y", move_amount) elif key == QtCore.Qt.Key_Left: self.__active_object.aim("x", -move_amount) elif key == QtCore.Qt.Key_Right: self.__active_object.aim("x", move_amount) elif key == QtCore.Qt.Key_Shift: self.__shift_held = True elif key == QtCore.Qt.Key_Alt: self.__alt_held = True elif key == QtCore.Qt.Key_Control: self.__control_held = True elif key == QtCore.Qt.Key_Space: self.__centre() elif key == QtCore.Qt.Key_Home and (event.modifiers() & QtCore.Qt.ShiftModifier): self.set_home_point() elif key == QtCore.Qt.Key_Home: self.go_to_home_point() elif key == QtCore.Qt.Key_Delete: if self.scene.selection: self.scene.remove(self.scene.selection) self.scene.selection = None elif key == QtCore.Qt.Key_C and (event.modifiers() & QtCore.Qt.ControlModifier): if self.scene.selection: self.scene.copy_selection() elif key == QtCore.Qt.Key_V and (event.modifiers() & QtCore.Qt.ControlModifier): # Paste at mouse position or viewport centre # Convert from screen coordinates to scene coordinates scene_origin = self.scene.origin scene_zoomlevel = self.scene.zoomlevel scale = 2**-scene_zoomlevel if self.__mousepos: # Convert mouse position from screen to scene coordinates mouse_x, mouse_y = self.__mousepos scene_x = (mouse_x - scene_origin[0]) * scale scene_y = (mouse_y - scene_origin[1]) * scale paste_pos = (scene_x, scene_y) else: # Calculate viewport centre in scene coordinates # Viewport centre is at (width/2, height/2) in screen coordinates viewport_centre_x = self.width() / 2 viewport_centre_y = self.height() / 2 scene_x = (viewport_centre_x - scene_origin[0]) * scale scene_y = (viewport_centre_y - scene_origin[1]) * scale paste_pos = (scene_x, scene_y) self.scene.paste(paste_pos) else: QtWidgets.QWidget.keyPressEvent(self, event)
[docs] def keyReleaseEvent(self, event: QtGui.QKeyEvent) -> None: """ Method : keyReleaseEvent(event) Parameters : event : QtGui.QKeyEvent keyReleaseEvent(event) --> None Handle key release events. """ key = event.key() if key == QtCore.Qt.Key_Shift: self.__shift_held = False elif key == QtCore.Qt.Key_Alt: self.__alt_held = False elif key == QtCore.Qt.Key_Control: # Clear rectangle drawing state when Control key is released if self.__drawing_rect: self.__drawing_rect = False self.__rect_start = None self.__rect_end = None # Trigger repaint to clear drawn rectangle self.update() self.__control_held = False else: QtWidgets.QWidget.keyPressEvent(self, event)
[docs] def _dispatch_pdf_page_nav(self, key: int, modifiers: QtCore.Qt.KeyboardModifier) -> None: """ Dispatch page navigation keys to the active PdfMediaObject, if any. """ active = self.__active_object if active is self.scene: return objects = active if isinstance(active, list) else [active] for obj in objects: if hasattr(obj, "handle_key") and obj.handle_key(key, modifiers): break
[docs] def resizeEvent(self, event: QtGui.QResizeEvent) -> None: """ Method : resizeEvent(event) Parameters : event : QtGui.QResizeEvent resizeEvent(event) --> None Handle resize events to update the viewport size. """ self.__scene.viewport_size = (self.width(), self.height()) # Defer zoom animation to next event loop iteration so the widget # has its final layout size (avoids running at intermediate sizes # during progressive layout passes with QTabWidget). if self.__scene_animation_pending: QtCore.QTimer.singleShot(0, self._run_pending_animation)
[docs] def _run_pending_animation(self) -> None: """Run the deferred zoom-in animation once the widget is sized.""" if not self.__scene_animation_pending: return self.__scene_animation_pending = False if self.width() > 0 and self.height() > 0: try: if hasattr(self.__scene, "_Scene__objects") and len(self.__scene._Scene__objects) > 0: target_z = getattr(self, "_pending_target_z", None) if target_z is not None: self.__scene.zoomlevel = target_z origin = getattr(self, "_pending_target_origin", None) if origin is not None: self.__scene.origin = origin del self._pending_target_z del self._pending_target_origin self.__scene.vx = self.__scene.vy = self.__scene.vz = 0.0 self.__scene.centre = (self.width() / 2, self.height() / 2) self.__scene.zoom(-5.0) self.__scene.aim("z", 5.0) except (TypeError, AttributeError): pass
[docs] def focusOutEvent(self, event: QtGui.QFocusEvent) -> None: """ Method : focusOutEvent(event) Parameters : event : QtGui.QFocusEvent focusOutEvent(event) --> None Handle focus out events to reset keyboard modifiers and rectangle drawing state. This prevents rectangle drawing from persisting when widget loses focus (e.g., during save dialog). """ # Reset all keyboard modifiers self.__shift_held = False self.__alt_held = False self.__control_held = False # Reset rectangle drawing state self.__drawing_rect = False self.__rect_start = None self.__rect_end = None # Trigger repaint to clear any drawn rectangle self.update() QtWidgets.QWidget.focusOutEvent(self, event)
@property def __active_object(self) -> Any: """ Property : __active_object Parameters : None __active_object --> Any Return the currently active object (either selected object or scene). """ if self.scene.selection and not self.__shift_held: return self.scene.selection else: return self.scene def __get_framerate(self) -> int: """ Method : __get_framerate() Parameters : None __get_framerate() --> int Return the rendering framerate. """ return self.__framerate def __set_framerate(self, framerate: int) -> None: """ Method : __set_framerate(framerate) Parameters : framerate : int __set_framerate(framerate) --> None Set the rendering framerate. """ self.__framerate = framerate if self.__framerate: self.__timer.start(int(1000 / self.__framerate), self) elif self.__timer.isActive(): self.__timer.stop() framerate = property(__get_framerate, __set_framerate) def __get_scene(self) -> "Scene.Scene": """ Method : __get_scene() Parameters : None __get_scene() --> Scene.Scene Return the scene currently being viewed. """ return self.__scene def __set_scene(self, scene: "Scene.Scene") -> None: """ Method : __set_scene(scene) Parameters : scene : Scene.Scene __set_scene(scene) --> None Set the scene to be viewed. """ # Stop autosave on current scene before replacement. # Only call disable_autosave if it is actually active — # avoids redundant QTimer.stop() and prevents log noise # from the rapid disable/enable/disable cycle during # scene open / new-scene operations. if self.__scene and hasattr(self.__scene, "_Scene__autosave_manager"): try: mgr = self.__scene._Scene__autosave_manager if mgr._autosave_active: mgr.disable_autosave() except Exception: pass # Scene is being replaced anyway TileManager.purge() self.__scene = scene target_z = scene.zoomlevel target_origin = scene.origin if self.width() > 0 and self.height() > 0: self.__scene.viewport_size = (self.width(), self.height()) ## Reset mouse/keyboard and rectangle selection state when loading new scene self.__mouse_right_down = False self.__mouse_left_down = False self.__mousepos = None self.__shift_held = False self.__alt_held = False self.__control_held = False self.__dropped_frames = 0 self.__draft = True # Reset rectangle drawing state self.__drawing_rect = False self.__rect_start = None self.__rect_end = None # Reset home point self._home_point = None self._home_point_timestamp = 0.0 # Apply zoom animation only for loaded scenes (not new scenes) # New scenes should use the default_zoomlevel from config # Loaded scenes have objects, new scenes are empty # Defer animation if widget not visible yet (during init with QTabWidget) # or hasn't been sized yet try: if hasattr(self.__scene, "_Scene__objects") and len(self.__scene._Scene__objects) > 0: if self.width() > 0 and self.height() > 0 and self.isVisible(): self.__scene.zoomlevel = target_z self.__scene.origin = target_origin self.__scene.vx = self.__scene.vy = self.__scene.vz = 0.0 self.__scene.centre = (self.width() / 2, self.height() / 2) self.__scene.zoom(-5.0) self.__scene.aim("z", 5.0) self.__scene_animation_pending = False else: self._pending_target_z = target_z self._pending_target_origin = target_origin self.__scene_animation_pending = True except (TypeError, AttributeError): # Handle mock objects in tests pass scene = property(__get_scene, __set_scene)