More CI fixes and mypy fixes
This commit is contained in:
parent
fd0758dd6c
commit
1fe44e7d8a
@ -14,24 +14,13 @@ jobs:
|
|||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
|
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.11', '3.12', '3.13', '3.14']
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Install project
|
||||||
uses: actions/setup-python@v4
|
run: pip3 install -e . --no-deps --break-system-packages
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
|
|
||||||
- name: Install Python dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -e ".[dev]"
|
|
||||||
pip install setuptools coverage-badge interrogate
|
|
||||||
|
|
||||||
- name: Download initial failed badges
|
- name: Download initial failed badges
|
||||||
run: |
|
run: |
|
||||||
@ -71,7 +60,7 @@ jobs:
|
|||||||
- name: Generate coverage reports
|
- name: Generate coverage reports
|
||||||
if: steps.pytest.outcome == 'success'
|
if: steps.pytest.outcome == 'success'
|
||||||
run: |
|
run: |
|
||||||
python -c "
|
python3 -c "
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
if os.path.exists('coverage.json'):
|
if os.path.exists('coverage.json'):
|
||||||
@ -100,15 +89,8 @@ jobs:
|
|||||||
name: coverage-reports
|
name: coverage-reports
|
||||||
path: cov_info/
|
path: cov_info/
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
|
||||||
if: matrix.python-version == '3.11'
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
with:
|
|
||||||
file: ./coverage.xml
|
|
||||||
fail_ci_if_error: false
|
|
||||||
|
|
||||||
- name: Commit badges to badges branch
|
- name: Commit badges to badges branch
|
||||||
if: github.ref == 'refs/heads/master' && matrix.python-version == '3.11'
|
if: github.ref == 'refs/heads/master'
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "action@gitea.local"
|
git config --local user.email "action@gitea.local"
|
||||||
git config --local user.name "Gitea Action"
|
git config --local user.name "Gitea Action"
|
||||||
|
|||||||
@ -6,9 +6,9 @@ FROM ubuntu:24.04
|
|||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
--no-install-recommends \
|
||||||
# Python
|
# Python
|
||||||
python3 \
|
python3 \
|
||||||
python3-venv \
|
|
||||||
python3-pip \
|
python3-pip \
|
||||||
# PyQt6 / OpenGL runtime deps
|
# PyQt6 / OpenGL runtime deps
|
||||||
libgl1 \
|
libgl1 \
|
||||||
@ -42,3 +42,24 @@ RUN apt-get update && apt-get install -y \
|
|||||||
git \
|
git \
|
||||||
nodejs \
|
nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install all Python dependencies so CI runs don't need to download anything
|
||||||
|
RUN pip3 install --break-system-packages --no-cache-dir \
|
||||||
|
# Runtime deps
|
||||||
|
PyQt6 \
|
||||||
|
PyOpenGL \
|
||||||
|
numpy \
|
||||||
|
Pillow \
|
||||||
|
reportlab \
|
||||||
|
lxml \
|
||||||
|
pypdf \
|
||||||
|
# Dev/test deps
|
||||||
|
pytest \
|
||||||
|
pytest-qt \
|
||||||
|
pytest-cov \
|
||||||
|
pytest-mock \
|
||||||
|
pdfplumber \
|
||||||
|
flake8 \
|
||||||
|
coverage-badge \
|
||||||
|
interrogate \
|
||||||
|
setuptools
|
||||||
|
|||||||
@ -345,7 +345,7 @@ class AssetManager:
|
|||||||
Returns:
|
Returns:
|
||||||
List of relative paths to unused assets
|
List of relative paths to unused assets
|
||||||
"""
|
"""
|
||||||
unused = []
|
unused: list[str] = []
|
||||||
|
|
||||||
if not os.path.exists(self.assets_folder):
|
if not os.path.exists(self.assets_folder):
|
||||||
return unused
|
return unused
|
||||||
|
|||||||
@ -405,7 +405,7 @@ class AsyncImageLoader(QObject):
|
|||||||
Returns:
|
Returns:
|
||||||
Processed PIL Image
|
Processed PIL Image
|
||||||
"""
|
"""
|
||||||
img = Image.open(path)
|
img: Image.Image = Image.open(path)
|
||||||
img = convert_to_rgba(img)
|
img = convert_to_rgba(img)
|
||||||
|
|
||||||
# Downsample if target size specified (preserving aspect ratio)
|
# Downsample if target size specified (preserving aspect ratio)
|
||||||
@ -485,6 +485,7 @@ class AsyncImageLoader(QObject):
|
|||||||
self._pending_requests[path] = request
|
self._pending_requests[path] = request
|
||||||
|
|
||||||
# Submit to queue (thread-safe)
|
# Submit to queue (thread-safe)
|
||||||
|
assert self._queue is not None
|
||||||
asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
|
asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
|
||||||
|
|
||||||
logger.debug(f"Queued load: {path} (priority: {priority.name})")
|
logger.debug(f"Queued load: {path} (priority: {priority.name})")
|
||||||
@ -768,8 +769,8 @@ class AsyncPDFGenerator(QObject):
|
|||||||
|
|
||||||
# Temporarily patch Image.open
|
# Temporarily patch Image.open
|
||||||
try:
|
try:
|
||||||
Image.open = cached_open
|
Image.open = cached_open # type: ignore[assignment]
|
||||||
return exporter.export(output_path, progress_callback)
|
return exporter.export(output_path, progress_callback) # type: ignore[no-any-return]
|
||||||
finally:
|
finally:
|
||||||
# Restore original
|
# Restore original
|
||||||
Image.open = original_open
|
Image.open = original_open
|
||||||
|
|||||||
@ -42,6 +42,7 @@ def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement:
|
|||||||
"""
|
"""
|
||||||
elem_type = elem_data.get("type")
|
elem_type = elem_data.get("type")
|
||||||
|
|
||||||
|
element: BaseLayoutElement
|
||||||
if elem_type == "image":
|
if elem_type == "image":
|
||||||
element = ImageData()
|
element = ImageData()
|
||||||
elif elem_type == "placeholder":
|
elif elem_type == "placeholder":
|
||||||
@ -759,6 +760,8 @@ class CommandHistory:
|
|||||||
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
|
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
|
||||||
"""Deserialize a single command using registry pattern"""
|
"""Deserialize a single command using registry pattern"""
|
||||||
cmd_type = data.get("type")
|
cmd_type = data.get("type")
|
||||||
|
if cmd_type is None:
|
||||||
|
return None
|
||||||
|
|
||||||
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
|
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
|
||||||
if not deserializer:
|
if not deserializer:
|
||||||
|
|||||||
@ -310,7 +310,7 @@ class FrameManager:
|
|||||||
|
|
||||||
# Load as PIL Image from bytes buffer
|
# Load as PIL Image from bytes buffer
|
||||||
buffer = io.BytesIO(png_data)
|
buffer = io.BytesIO(png_data)
|
||||||
img = Image.open(buffer)
|
img: Image.Image = Image.open(buffer)
|
||||||
if img.mode != "RGBA":
|
if img.mode != "RGBA":
|
||||||
img = img.convert("RGBA")
|
img = img.convert("RGBA")
|
||||||
|
|
||||||
@ -836,11 +836,11 @@ class FrameManager:
|
|||||||
|
|
||||||
# Flip horizontally if h position differs
|
# Flip horizontally if h position differs
|
||||||
if asset_h != target_h:
|
if asset_h != target_h:
|
||||||
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||||
|
|
||||||
# Flip vertically if v position differs
|
# Flip vertically if v position differs
|
||||||
if asset_v != target_v:
|
if asset_v != target_v:
|
||||||
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ Async loading mixin for non-blocking image loading and PDF generation.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional, cast
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from PyQt6.QtCore import QObject
|
from PyQt6.QtCore import QObject
|
||||||
@ -22,11 +22,11 @@ class AsyncLoadingMixin:
|
|||||||
# Type hints for expected attributes from mixing class
|
# Type hints for expected attributes from mixing class
|
||||||
_pdf_progress_dialog: Optional[QProgressDialog]
|
_pdf_progress_dialog: Optional[QProgressDialog]
|
||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None: # type: ignore[empty-body]
|
||||||
"""Expected from QWidget"""
|
"""Expected from QWidget"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def window(self) -> "QMainWindow":
|
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||||
"""Expected from QWidget"""
|
"""Expected from QWidget"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@ -224,7 +224,9 @@ class AsyncLoadingMixin:
|
|||||||
|
|
||||||
total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
|
total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
|
||||||
|
|
||||||
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, self)
|
from PyQt6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, cast(QWidget, self))
|
||||||
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
|
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
|
||||||
self._pdf_progress_dialog.setWindowTitle("PDF Export")
|
self._pdf_progress_dialog.setWindowTitle("PDF Export")
|
||||||
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
|
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
|
||||||
|
|||||||
@ -15,7 +15,7 @@ class ElementManipulationMixin:
|
|||||||
drag_start_pos: Optional[Tuple[float, float]]
|
drag_start_pos: Optional[Tuple[float, float]]
|
||||||
drag_start_element_pos: Optional[Tuple[float, float]]
|
drag_start_element_pos: Optional[Tuple[float, float]]
|
||||||
|
|
||||||
def window(self) -> "QMainWindow":
|
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||||
"""Expected from QWidget"""
|
"""Expected from QWidget"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@ -62,12 +62,12 @@ class ElementManipulationMixin:
|
|||||||
self._resize_element_no_snap(dx, dy)
|
self._resize_element_no_snap(dx, dy)
|
||||||
return
|
return
|
||||||
|
|
||||||
parent_page = self.selected_element._parent_page
|
parent_page = self.selected_element._parent_page # type: ignore[attr-defined]
|
||||||
snap_sys = parent_page.layout.snapping_system
|
snap_sys = parent_page.layout.snapping_system
|
||||||
|
|
||||||
# Get page size
|
# Get page size
|
||||||
page_size = parent_page.layout.size
|
page_size = parent_page.layout.size
|
||||||
dpi = main_window.project.working_dpi
|
dpi = main_window.project.working_dpi # type: ignore[attr-defined]
|
||||||
|
|
||||||
# Apply snapping to resize
|
# Apply snapping to resize
|
||||||
from pyPhotoAlbum.snapping import SnapResizeParams
|
from pyPhotoAlbum.snapping import SnapResizeParams
|
||||||
@ -80,7 +80,7 @@ class ElementManipulationMixin:
|
|||||||
resize_handle=self.resize_handle,
|
resize_handle=self.resize_handle,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
dpi=dpi,
|
dpi=dpi,
|
||||||
project=main_window.project,
|
project=main_window.project, # type: ignore[attr-defined]
|
||||||
)
|
)
|
||||||
new_pos, new_size = snap_sys.snap_resize(params)
|
new_pos, new_size = snap_sys.snap_resize(params)
|
||||||
|
|
||||||
@ -106,6 +106,8 @@ class ElementManipulationMixin:
|
|||||||
"""
|
"""
|
||||||
if not self.resize_start_pos or not self.resize_start_size:
|
if not self.resize_start_pos or not self.resize_start_size:
|
||||||
return
|
return
|
||||||
|
if self.selected_element is None:
|
||||||
|
return
|
||||||
|
|
||||||
start_x, start_y = self.resize_start_pos
|
start_x, start_y = self.resize_start_pos
|
||||||
start_w, start_h = self.resize_start_size
|
start_w, start_h = self.resize_start_size
|
||||||
|
|||||||
@ -14,7 +14,7 @@ class ElementSelectionMixin:
|
|||||||
# Type hints for expected attributes from mixing class
|
# Type hints for expected attributes from mixing class
|
||||||
_page_renderers: list
|
_page_renderers: list
|
||||||
|
|
||||||
def window(self) -> "QMainWindow":
|
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||||
"""Expected from QWidget"""
|
"""Expected from QWidget"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ class ElementSelectionMixin:
|
|||||||
# Store the renderer with the element for later use
|
# Store the renderer with the element for later use
|
||||||
element._page_renderer = renderer # type: ignore[attr-defined]
|
element._page_renderer = renderer # type: ignore[attr-defined]
|
||||||
element._parent_page = page # type: ignore[attr-defined]
|
element._parent_page = page # type: ignore[attr-defined]
|
||||||
return element
|
return element # type: ignore[no-any-return]
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@ -123,7 +123,7 @@ class InteractionState:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with non-None state values
|
Dict with non-None state values
|
||||||
"""
|
"""
|
||||||
state = {}
|
state: Dict[str, Any] = {}
|
||||||
if self.position is not None:
|
if self.position is not None:
|
||||||
state["position"] = self.position
|
state["position"] = self.position
|
||||||
if self.size is not None:
|
if self.size is not None:
|
||||||
|
|||||||
@ -3,9 +3,14 @@ Mouse interaction mixin for GLWidget - coordinates all mouse events
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from typing import TYPE_CHECKING, Any, Optional, Set
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
from pyPhotoAlbum.models import ImageData
|
from PyQt6.QtGui import QCursor
|
||||||
|
from pyPhotoAlbum.models import ImageData, BaseLayoutElement
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from PyQt6.QtWidgets import QMainWindow
|
||||||
|
|
||||||
|
|
||||||
class MouseInteractionMixin:
|
class MouseInteractionMixin:
|
||||||
@ -16,6 +21,39 @@ class MouseInteractionMixin:
|
|||||||
the current interaction state.
|
the current interaction state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Type declarations for attributes provided by other mixins/base classes
|
||||||
|
selected_elements: Set[BaseLayoutElement]
|
||||||
|
selected_element: Optional[BaseLayoutElement]
|
||||||
|
rotation_mode: bool
|
||||||
|
rotation_snap_angle: int
|
||||||
|
rotation_start_angle: Optional[float]
|
||||||
|
pan_offset: list
|
||||||
|
zoom_level: float
|
||||||
|
image_pan_mode: bool
|
||||||
|
current_page_index: int
|
||||||
|
_page_renderers: list
|
||||||
|
resize_start_pos: Optional[Any]
|
||||||
|
resize_start_size: Optional[Any]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
def window(self) -> "QMainWindow": ...
|
||||||
|
def update(self) -> None: ...
|
||||||
|
def setCursor(self, cursor: Any) -> None: ...
|
||||||
|
def setFocus(self, *args: Any) -> None: ...
|
||||||
|
def _begin_rotate(self, element: Any) -> None: ...
|
||||||
|
def _begin_resize(self, element: Any) -> None: ...
|
||||||
|
def _begin_image_pan(self, element: Any) -> None: ...
|
||||||
|
def _begin_move(self, element: Any) -> None: ...
|
||||||
|
def _end_interaction(self) -> None: ...
|
||||||
|
def _resize_element(self, dx: float, dy: float) -> None: ...
|
||||||
|
def _get_page_at(self, x: float, y: float) -> Any: ...
|
||||||
|
def _get_element_at(self, x: float, y: float) -> Optional[BaseLayoutElement]: ...
|
||||||
|
def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]: ...
|
||||||
|
def _check_ghost_page_click(self, x: float, y: float) -> bool: ...
|
||||||
|
def _transfer_element_to_page(self, element: Any, source: Any, target: Any, x: float, y: float, renderer: Any) -> None: ...
|
||||||
|
def _handle_image_pan_move(self, x: float, y: float, element: Any) -> None: ...
|
||||||
|
def _update_page_status(self, x: float, y: float) -> None: ...
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
@ -27,6 +65,7 @@ class MouseInteractionMixin:
|
|||||||
|
|
||||||
def _handle_rotation_start(self, x: float, y: float):
|
def _handle_rotation_start(self, x: float, y: float):
|
||||||
"""Start rotation interaction for selected element."""
|
"""Start rotation interaction for selected element."""
|
||||||
|
assert self.selected_element is not None
|
||||||
self._begin_rotate(self.selected_element)
|
self._begin_rotate(self.selected_element)
|
||||||
self.drag_start_pos = (x, y)
|
self.drag_start_pos = (x, y)
|
||||||
self.rotation_start_angle = self.selected_element.rotation
|
self.rotation_start_angle = self.selected_element.rotation
|
||||||
@ -34,6 +73,7 @@ class MouseInteractionMixin:
|
|||||||
|
|
||||||
def _handle_resize_start(self, x: float, y: float, handle):
|
def _handle_resize_start(self, x: float, y: float, handle):
|
||||||
"""Start resize interaction for selected element."""
|
"""Start resize interaction for selected element."""
|
||||||
|
assert self.selected_element is not None
|
||||||
self._begin_resize(self.selected_element)
|
self._begin_resize(self.selected_element)
|
||||||
self.resize_handle = handle
|
self.resize_handle = handle
|
||||||
self.drag_start_pos = (x, y)
|
self.drag_start_pos = (x, y)
|
||||||
@ -139,10 +179,10 @@ class MouseInteractionMixin:
|
|||||||
|
|
||||||
def _handle_rotation_move(self, x: float, y: float):
|
def _handle_rotation_move(self, x: float, y: float):
|
||||||
"""Handle element rotation during drag."""
|
"""Handle element rotation during drag."""
|
||||||
if not hasattr(self.selected_element, "_page_renderer"):
|
if self.selected_element is None or not hasattr(self.selected_element, "_page_renderer"):
|
||||||
return
|
return
|
||||||
|
|
||||||
renderer = self.selected_element._page_renderer
|
renderer = self.selected_element._page_renderer # type: ignore[attr-defined]
|
||||||
elem_x, elem_y = self.selected_element.position
|
elem_x, elem_y = self.selected_element.position
|
||||||
elem_w, elem_h = self.selected_element.size
|
elem_w, elem_h = self.selected_element.size
|
||||||
|
|
||||||
@ -174,10 +214,11 @@ class MouseInteractionMixin:
|
|||||||
|
|
||||||
def _handle_element_move(self, x: float, y: float):
|
def _handle_element_move(self, x: float, y: float):
|
||||||
"""Handle element movement during drag, including page transfer."""
|
"""Handle element movement during drag, including page transfer."""
|
||||||
|
assert self.selected_element is not None
|
||||||
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
|
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
|
||||||
|
|
||||||
if current_page and hasattr(self.selected_element, "_parent_page"):
|
if current_page and hasattr(self.selected_element, "_parent_page"):
|
||||||
source_page = self.selected_element._parent_page
|
source_page = self.selected_element._parent_page # type: ignore[attr-defined]
|
||||||
|
|
||||||
if current_page is not source_page:
|
if current_page is not source_page:
|
||||||
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
|
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
|
||||||
@ -201,17 +242,18 @@ class MouseInteractionMixin:
|
|||||||
new_x = self.drag_start_element_pos[0] + total_dx
|
new_x = self.drag_start_element_pos[0] + total_dx
|
||||||
new_y = self.drag_start_element_pos[1] + total_dy
|
new_y = self.drag_start_element_pos[1] + total_dy
|
||||||
|
|
||||||
|
assert self.selected_element is not None
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
snap_sys = page.layout.snapping_system
|
snap_sys = page.layout.snapping_system
|
||||||
page_size = page.layout.size
|
page_size = page.layout.size
|
||||||
dpi = main_window.project.working_dpi
|
dpi = main_window.project.working_dpi # type: ignore[attr-defined]
|
||||||
|
|
||||||
snapped_pos = snap_sys.snap_position(
|
snapped_pos = snap_sys.snap_position(
|
||||||
position=(new_x, new_y),
|
position=(new_x, new_y),
|
||||||
size=self.selected_element.size,
|
size=self.selected_element.size,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
dpi=dpi,
|
dpi=dpi,
|
||||||
project=main_window.project,
|
project=main_window.project, # type: ignore[attr-defined]
|
||||||
)
|
)
|
||||||
|
|
||||||
self.selected_element.position = snapped_pos
|
self.selected_element.position = snapped_pos
|
||||||
|
|||||||
@ -23,15 +23,15 @@ class AlignmentOperationsMixin:
|
|||||||
status_msg: Status message format string (will receive element count)
|
status_msg: Status message format string (will receive element count)
|
||||||
"""
|
"""
|
||||||
elements = self._get_selected_elements_list()
|
elements = self._get_selected_elements_list()
|
||||||
if not self.require_selection(min_count=2):
|
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
changes = alignment_func(elements)
|
changes = alignment_func(elements)
|
||||||
if changes:
|
if changes:
|
||||||
cmd = AlignElementsCommand(changes)
|
cmd = AlignElementsCommand(changes)
|
||||||
self.project.history.execute(cmd)
|
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||||
self.update_view()
|
self.update_view() # type: ignore[attr-defined]
|
||||||
self.show_status(status_msg.format(len(elements)), 2000)
|
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
label="Align Left",
|
label="Align Left",
|
||||||
|
|||||||
@ -23,15 +23,15 @@ class DistributionOperationsMixin:
|
|||||||
status_msg: Status message format string (will receive element count)
|
status_msg: Status message format string (will receive element count)
|
||||||
"""
|
"""
|
||||||
elements = self._get_selected_elements_list()
|
elements = self._get_selected_elements_list()
|
||||||
if not self.require_selection(min_count=3):
|
if not self.require_selection(min_count=3): # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
changes = distribution_func(elements)
|
changes = distribution_func(elements)
|
||||||
if changes:
|
if changes:
|
||||||
cmd = AlignElementsCommand(changes)
|
cmd = AlignElementsCommand(changes)
|
||||||
self.project.history.execute(cmd)
|
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||||
self.update_view()
|
self.update_view() # type: ignore[attr-defined]
|
||||||
self.show_status(status_msg.format(len(elements)), 2000)
|
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
label="Distribute H",
|
label="Distribute H",
|
||||||
|
|||||||
@ -301,7 +301,7 @@ class FileOperationsMixin:
|
|||||||
|
|
||||||
if not file_path:
|
if not file_path:
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
||||||
if not file_path:
|
if not file_path:
|
||||||
@ -319,7 +319,7 @@ class FileOperationsMixin:
|
|||||||
|
|
||||||
# Bridge object: signals are thread-safe so background thread can
|
# Bridge object: signals are thread-safe so background thread can
|
||||||
# emit them and slots always run on the main (GUI) thread.
|
# emit them and slots always run on the main (GUI) thread.
|
||||||
bridge = _SaveBridge(parent=self)
|
bridge = _SaveBridge(parent=self) # type: ignore[arg-type]
|
||||||
|
|
||||||
def _on_progress(progress: int, message: str):
|
def _on_progress(progress: int, message: str):
|
||||||
if hasattr(self, "_loading_widget"):
|
if hasattr(self, "_loading_widget"):
|
||||||
@ -344,7 +344,7 @@ class FileOperationsMixin:
|
|||||||
print(f"Successfully saved project to: {file_path}")
|
print(f"Successfully saved project to: {file_path}")
|
||||||
if getattr(self, "_pending_close", False):
|
if getattr(self, "_pending_close", False):
|
||||||
self._pending_close = False
|
self._pending_close = False
|
||||||
self.close()
|
self.close() # type: ignore[attr-defined]
|
||||||
else:
|
else:
|
||||||
self._pending_close = False
|
self._pending_close = False
|
||||||
error_msg = f"Failed to save project: {error}"
|
error_msg = f"Failed to save project: {error}"
|
||||||
@ -419,7 +419,9 @@ class FileOperationsMixin:
|
|||||||
"then click 'Attempt Healing' to find and import them."
|
"then click 'Attempt Healing' to find and import them."
|
||||||
)
|
)
|
||||||
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||||
msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets")
|
btn = msg.button(QMessageBox.StandardButton.Open)
|
||||||
|
if btn is not None:
|
||||||
|
btn.setText("Open Heal Assets")
|
||||||
|
|
||||||
result = msg.exec()
|
result = msg.exec()
|
||||||
if result == QMessageBox.StandardButton.Open:
|
if result == QMessageBox.StandardButton.Open:
|
||||||
|
|||||||
@ -23,15 +23,15 @@ class SizeOperationsMixin:
|
|||||||
status_msg: Status message format string (will receive element count)
|
status_msg: Status message format string (will receive element count)
|
||||||
"""
|
"""
|
||||||
elements = self._get_selected_elements_list()
|
elements = self._get_selected_elements_list()
|
||||||
if not self.require_selection(min_count=2):
|
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
changes = resize_func(elements)
|
changes = resize_func(elements)
|
||||||
if changes:
|
if changes:
|
||||||
cmd = ResizeElementsCommand(changes)
|
cmd = ResizeElementsCommand(changes)
|
||||||
self.project.history.execute(cmd)
|
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||||
self.update_view()
|
self.update_view() # type: ignore[attr-defined]
|
||||||
self.show_status(status_msg.format(len(elements)), 2000)
|
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
|
||||||
|
|
||||||
def _execute_fit_to_page(self, fit_func, status_msg: str):
|
def _execute_fit_to_page(self, fit_func, status_msg: str):
|
||||||
"""
|
"""
|
||||||
@ -41,22 +41,22 @@ class SizeOperationsMixin:
|
|||||||
fit_func: Function that takes (element, page) and returns a change tuple
|
fit_func: Function that takes (element, page) and returns a change tuple
|
||||||
status_msg: Status message to display on success
|
status_msg: Status message to display on success
|
||||||
"""
|
"""
|
||||||
if not self.require_selection(min_count=1):
|
if not self.require_selection(min_count=1): # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
page = self.get_current_page()
|
page = self.get_current_page() # type: ignore[attr-defined]
|
||||||
if not page:
|
if not page:
|
||||||
self.show_warning("No Page", "Please create a page first.")
|
self.show_warning("No Page", "Please create a page first.") # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
element = next(iter(self.gl_widget.selected_elements))
|
element = next(iter(self.gl_widget.selected_elements)) # type: ignore[attr-defined]
|
||||||
change = fit_func(element, page)
|
change = fit_func(element, page)
|
||||||
|
|
||||||
if change:
|
if change:
|
||||||
cmd = ResizeElementsCommand([change])
|
cmd = ResizeElementsCommand([change])
|
||||||
self.project.history.execute(cmd)
|
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||||
self.update_view()
|
self.update_view() # type: ignore[attr-defined]
|
||||||
self.show_status(status_msg, 2000)
|
self.show_status(status_msg, 2000) # type: ignore[attr-defined]
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
label="Same Size",
|
label="Same Size",
|
||||||
|
|||||||
@ -31,7 +31,7 @@ class StyleOperationsMixin:
|
|||||||
"""
|
"""
|
||||||
images = self._get_selected_images()
|
images = self._get_selected_images()
|
||||||
if not images:
|
if not images:
|
||||||
self.show_status("No images selected", 2000)
|
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
# Store old styles for undo
|
# Store old styles for undo
|
||||||
@ -86,10 +86,10 @@ class StyleOperationsMixin:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
cmd = StyleChangeCommand(old_styles, style_updater, description)
|
cmd = StyleChangeCommand(old_styles, style_updater, description)
|
||||||
self.project.history.execute(cmd)
|
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||||
|
|
||||||
self.update_view()
|
self.update_view() # type: ignore[attr-defined]
|
||||||
self.show_status(f"{description} applied to {len(images)} image(s)", 2000)
|
self.show_status(f"{description} applied to {len(images)} image(s)", 2000) # type: ignore[attr-defined]
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Corner Radius
|
# Corner Radius
|
||||||
@ -106,7 +106,7 @@ class StyleOperationsMixin:
|
|||||||
"""Show dialog to set corner radius"""
|
"""Show dialog to set corner radius"""
|
||||||
images = self._get_selected_images()
|
images = self._get_selected_images()
|
||||||
if not images:
|
if not images:
|
||||||
self.show_status("No images selected", 2000)
|
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
|
from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
|
||||||
@ -151,7 +151,7 @@ class StyleOperationsMixin:
|
|||||||
"""Show dialog to configure border"""
|
"""Show dialog to configure border"""
|
||||||
images = self._get_selected_images()
|
images = self._get_selected_images()
|
||||||
if not images:
|
if not images:
|
||||||
self.show_status("No images selected", 2000)
|
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
|
from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
|
||||||
@ -198,7 +198,7 @@ class StyleOperationsMixin:
|
|||||||
"""Show dialog to configure drop shadow"""
|
"""Show dialog to configure drop shadow"""
|
||||||
images = self._get_selected_images()
|
images = self._get_selected_images()
|
||||||
if not images:
|
if not images:
|
||||||
self.show_status("No images selected", 2000)
|
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
|
from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
|
||||||
@ -235,7 +235,7 @@ class StyleOperationsMixin:
|
|||||||
"""Toggle shadow enabled/disabled"""
|
"""Toggle shadow enabled/disabled"""
|
||||||
images = self._get_selected_images()
|
images = self._get_selected_images()
|
||||||
if not images:
|
if not images:
|
||||||
self.show_status("No images selected", 2000)
|
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
# Toggle based on first selected image
|
# Toggle based on first selected image
|
||||||
@ -330,7 +330,7 @@ class StyleOperationsMixin:
|
|||||||
"""Show dialog to select decorative frame"""
|
"""Show dialog to select decorative frame"""
|
||||||
images = self._get_selected_images()
|
images = self._get_selected_images()
|
||||||
if not images:
|
if not images:
|
||||||
self.show_status("No images selected", 2000)
|
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||||
return
|
return
|
||||||
|
|
||||||
from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog
|
from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog
|
||||||
|
|||||||
@ -13,11 +13,11 @@ class PageNavigationMixin:
|
|||||||
pan_offset: Tuple[float, float]
|
pan_offset: Tuple[float, float]
|
||||||
zoom_level: float
|
zoom_level: float
|
||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None: # type: ignore[empty-body]
|
||||||
"""Expected from QWidget"""
|
"""Expected from QWidget"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def window(self) -> "QMainWindow":
|
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||||
"""Expected from QWidget"""
|
"""Expected from QWidget"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|||||||
@ -181,7 +181,7 @@ class ViewportMixin:
|
|||||||
content_height = bounds["height"]
|
content_height = bounds["height"]
|
||||||
|
|
||||||
# Save original for page selection (prevents clamping from changing which page we target)
|
# Save original for page selection (prevents clamping from changing which page we target)
|
||||||
original_pan_y = self.pan_offset[1]
|
original_pan_y: float = self.pan_offset[1]
|
||||||
|
|
||||||
if content_height > viewport_height:
|
if content_height > viewport_height:
|
||||||
max_pan_up = 0 # Can't pan beyond top edge
|
max_pan_up = 0 # Can't pan beyond top edge
|
||||||
|
|||||||
@ -103,15 +103,15 @@ class ImageStyle:
|
|||||||
"""
|
"""
|
||||||
self.corner_radius = corner_radius
|
self.corner_radius = corner_radius
|
||||||
self.border_width = border_width
|
self.border_width = border_width
|
||||||
self.border_color = tuple(border_color)
|
self.border_color: Tuple[int, int, int] = border_color
|
||||||
self.shadow_enabled = shadow_enabled
|
self.shadow_enabled = shadow_enabled
|
||||||
self.shadow_offset = tuple(shadow_offset)
|
self.shadow_offset: Tuple[float, float] = shadow_offset
|
||||||
self.shadow_blur = shadow_blur
|
self.shadow_blur = shadow_blur
|
||||||
self.shadow_color = tuple(shadow_color)
|
self.shadow_color: Tuple[int, int, int, int] = shadow_color
|
||||||
self.frame_style = frame_style
|
self.frame_style = frame_style
|
||||||
self.frame_color = tuple(frame_color)
|
self.frame_color: Tuple[int, int, int] = frame_color
|
||||||
# frame_corners: (top_left, top_right, bottom_right, bottom_left)
|
# frame_corners: (top_left, top_right, bottom_right, bottom_left)
|
||||||
self.frame_corners = tuple(frame_corners) if frame_corners else (True, True, True, True)
|
self.frame_corners: Tuple[bool, bool, bool, bool] = frame_corners if frame_corners else (True, True, True, True)
|
||||||
|
|
||||||
def copy(self) -> "ImageStyle":
|
def copy(self) -> "ImageStyle":
|
||||||
"""Create a copy of this style."""
|
"""Create a copy of this style."""
|
||||||
@ -148,7 +148,7 @@ class ImageStyle:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def deserialize(cls, data: Dict[str, Any]) -> "ImageStyle":
|
def deserialize(cls, data: Optional[Dict[str, Any]]) -> "ImageStyle":
|
||||||
"""Deserialize style from dictionary."""
|
"""Deserialize style from dictionary."""
|
||||||
if data is None:
|
if data is None:
|
||||||
return cls()
|
return cls()
|
||||||
@ -526,7 +526,7 @@ class ImageData(BaseLayoutElement):
|
|||||||
|
|
||||||
frame_manager = get_frame_manager()
|
frame_manager = get_frame_manager()
|
||||||
frame_manager.render_frame_opengl(
|
frame_manager.render_frame_opengl(
|
||||||
frame_name=self.style.frame_style,
|
frame_name=self.style.frame_style, # type: ignore[arg-type]
|
||||||
x=x,
|
x=x,
|
||||||
y=y,
|
y=y,
|
||||||
width=w,
|
width=w,
|
||||||
|
|||||||
@ -121,7 +121,7 @@ class PageLayout:
|
|||||||
if hasattr(self, "_async_loader"):
|
if hasattr(self, "_async_loader"):
|
||||||
loader = self._async_loader
|
loader = self._async_loader
|
||||||
elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
|
elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
|
||||||
loader = self._parent_widget.async_image_loader
|
loader = self._parent_widget.async_image_loader # type: ignore[union-attr]
|
||||||
else:
|
else:
|
||||||
loader = None
|
loader = None
|
||||||
|
|
||||||
@ -131,7 +131,7 @@ class PageLayout:
|
|||||||
|
|
||||||
# Determine priority based on visibility (HIGH for now, can be refined)
|
# Determine priority based on visibility (HIGH for now, can be refined)
|
||||||
if hasattr(self._parent_widget, "request_image_load"):
|
if hasattr(self._parent_widget, "request_image_load"):
|
||||||
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH)
|
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) # type: ignore[union-attr]
|
||||||
element._async_load_requested = True
|
element._async_load_requested = True
|
||||||
element._async_loading = True
|
element._async_loading = True
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,7 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional
|
|||||||
|
|
||||||
# Try to open the image - this is the most likely failure point
|
# Try to open the image - this is the most likely failure point
|
||||||
try:
|
try:
|
||||||
img = Image.open(task.image_path)
|
img: Image.Image = Image.open(task.image_path)
|
||||||
except Exception as open_err:
|
except Exception as open_err:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
@ -389,7 +389,7 @@ class PDFExporter:
|
|||||||
Scans all pages and elements to build a list of ImageTask objects
|
Scans all pages and elements to build a list of ImageTask objects
|
||||||
that can be processed in parallel.
|
that can be processed in parallel.
|
||||||
"""
|
"""
|
||||||
tasks = []
|
tasks: List[ImageTask] = []
|
||||||
dpi = self.project.working_dpi
|
dpi = self.project.working_dpi
|
||||||
|
|
||||||
for page in self.project.pages:
|
for page in self.project.pages:
|
||||||
@ -580,7 +580,7 @@ class PDFExporter:
|
|||||||
img.load()
|
img.load()
|
||||||
# Store both image and buffer reference to prevent garbage collection
|
# Store both image and buffer reference to prevent garbage collection
|
||||||
# Some PIL operations may still reference the source buffer
|
# Some PIL operations may still reference the source buffer
|
||||||
img._ppa_buffer = buffer # Keep buffer alive with image
|
img._ppa_buffer = buffer # type: ignore[attr-defined] # Keep buffer alive with image
|
||||||
self._processed_images[task_id] = img
|
self._processed_images[task_id] = img
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -760,7 +760,7 @@ class PDFExporter:
|
|||||||
y_pt=y_pt,
|
y_pt=y_pt,
|
||||||
width_pt=width_pt,
|
width_pt=width_pt,
|
||||||
height_pt=height_pt,
|
height_pt=height_pt,
|
||||||
page_number=page_number,
|
page_number=int(page_number),
|
||||||
)
|
)
|
||||||
self._render_image(ctx)
|
self._render_image(ctx)
|
||||||
elif isinstance(element, TextBoxData):
|
elif isinstance(element, TextBoxData):
|
||||||
@ -943,7 +943,7 @@ class PDFExporter:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Load image using resolved path
|
# Load image using resolved path
|
||||||
img = Image.open(image_full_path)
|
img: Image.Image = Image.open(image_full_path)
|
||||||
img = convert_to_rgba(img)
|
img = convert_to_rgba(img)
|
||||||
|
|
||||||
# Apply PIL-level rotation if needed
|
# Apply PIL-level rotation if needed
|
||||||
@ -1072,7 +1072,7 @@ class PDFExporter:
|
|||||||
|
|
||||||
frame_manager.render_frame_pdf(
|
frame_manager.render_frame_pdf(
|
||||||
canvas=ctx.canvas,
|
canvas=ctx.canvas,
|
||||||
frame_name=style.frame_style,
|
frame_name=style.frame_style, # type: ignore[arg-type]
|
||||||
x_pt=ctx.x_pt,
|
x_pt=ctx.x_pt,
|
||||||
y_pt=ctx.y_pt,
|
y_pt=ctx.y_pt,
|
||||||
width_pt=ctx.width_pt,
|
width_pt=ctx.width_pt,
|
||||||
|
|||||||
@ -342,7 +342,7 @@ class Project:
|
|||||||
"""
|
"""
|
||||||
from pyPhotoAlbum.models import GhostPageData
|
from pyPhotoAlbum.models import GhostPageData
|
||||||
|
|
||||||
layout = []
|
layout: list[tuple[str, Optional["Page"], int]] = []
|
||||||
current_position = 1 # Start at position 1 (right page)
|
current_position = 1 # Start at position 1 (right page)
|
||||||
|
|
||||||
for page in self.pages:
|
for page in self.pages:
|
||||||
|
|||||||
@ -317,7 +317,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
|||||||
self.current_folder = folder_path
|
self.current_folder = folder_path
|
||||||
|
|
||||||
# Find all image files
|
# Find all image files
|
||||||
self.image_files = []
|
self.image_files: list[Path] = []
|
||||||
for ext in IMAGE_EXTENSIONS:
|
for ext in IMAGE_EXTENSIONS:
|
||||||
self.image_files.extend(folder_path.glob(f"*{ext}"))
|
self.image_files.extend(folder_path.glob(f"*{ext}"))
|
||||||
self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))
|
self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user