More CI fixes and mypy fixes
This commit is contained in:
parent
fd0758dd6c
commit
1fe44e7d8a
@ -14,24 +14,13 @@ jobs:
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
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: Install project
|
||||
run: pip3 install -e . --no-deps --break-system-packages
|
||||
|
||||
- name: Download initial failed badges
|
||||
run: |
|
||||
@ -71,7 +60,7 @@ jobs:
|
||||
- name: Generate coverage reports
|
||||
if: steps.pytest.outcome == 'success'
|
||||
run: |
|
||||
python -c "
|
||||
python3 -c "
|
||||
import json
|
||||
import os
|
||||
if os.path.exists('coverage.json'):
|
||||
@ -100,15 +89,8 @@ jobs:
|
||||
name: coverage-reports
|
||||
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
|
||||
if: github.ref == 'refs/heads/master' && matrix.python-version == '3.11'
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: |
|
||||
git config --local user.email "action@gitea.local"
|
||||
git config --local user.name "Gitea Action"
|
||||
|
||||
@ -6,9 +6,9 @@ FROM ubuntu:24.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
--no-install-recommends \
|
||||
# Python
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
# PyQt6 / OpenGL runtime deps
|
||||
libgl1 \
|
||||
@ -42,3 +42,24 @@ RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
nodejs \
|
||||
&& 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:
|
||||
List of relative paths to unused assets
|
||||
"""
|
||||
unused = []
|
||||
unused: list[str] = []
|
||||
|
||||
if not os.path.exists(self.assets_folder):
|
||||
return unused
|
||||
|
||||
@ -405,7 +405,7 @@ class AsyncImageLoader(QObject):
|
||||
Returns:
|
||||
Processed PIL Image
|
||||
"""
|
||||
img = Image.open(path)
|
||||
img: Image.Image = Image.open(path)
|
||||
img = convert_to_rgba(img)
|
||||
|
||||
# Downsample if target size specified (preserving aspect ratio)
|
||||
@ -485,6 +485,7 @@ class AsyncImageLoader(QObject):
|
||||
self._pending_requests[path] = request
|
||||
|
||||
# Submit to queue (thread-safe)
|
||||
assert self._queue is not None
|
||||
asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
|
||||
|
||||
logger.debug(f"Queued load: {path} (priority: {priority.name})")
|
||||
@ -768,8 +769,8 @@ class AsyncPDFGenerator(QObject):
|
||||
|
||||
# Temporarily patch Image.open
|
||||
try:
|
||||
Image.open = cached_open
|
||||
return exporter.export(output_path, progress_callback)
|
||||
Image.open = cached_open # type: ignore[assignment]
|
||||
return exporter.export(output_path, progress_callback) # type: ignore[no-any-return]
|
||||
finally:
|
||||
# Restore original
|
||||
Image.open = original_open
|
||||
|
||||
@ -42,6 +42,7 @@ def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement:
|
||||
"""
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
element: BaseLayoutElement
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
@ -759,6 +760,8 @@ class CommandHistory:
|
||||
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
|
||||
"""Deserialize a single command using registry pattern"""
|
||||
cmd_type = data.get("type")
|
||||
if cmd_type is None:
|
||||
return None
|
||||
|
||||
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
|
||||
if not deserializer:
|
||||
|
||||
@ -310,7 +310,7 @@ class FrameManager:
|
||||
|
||||
# Load as PIL Image from bytes buffer
|
||||
buffer = io.BytesIO(png_data)
|
||||
img = Image.open(buffer)
|
||||
img: Image.Image = Image.open(buffer)
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
|
||||
@ -836,11 +836,11 @@ class FrameManager:
|
||||
|
||||
# Flip horizontally if h position differs
|
||||
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
|
||||
if asset_v != target_v:
|
||||
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ Async loading mixin for non-blocking image loading and PDF generation.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
import logging
|
||||
|
||||
from PyQt6.QtCore import QObject
|
||||
@ -22,11 +22,11 @@ class AsyncLoadingMixin:
|
||||
# Type hints for expected attributes from mixing class
|
||||
_pdf_progress_dialog: Optional[QProgressDialog]
|
||||
|
||||
def update(self) -> None:
|
||||
def update(self) -> None: # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
def window(self) -> "QMainWindow":
|
||||
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||
"""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)
|
||||
|
||||
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.setWindowTitle("PDF Export")
|
||||
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_element_pos: Optional[Tuple[float, float]]
|
||||
|
||||
def window(self) -> "QMainWindow":
|
||||
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
@ -62,12 +62,12 @@ class ElementManipulationMixin:
|
||||
self._resize_element_no_snap(dx, dy)
|
||||
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
|
||||
|
||||
# Get page 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
|
||||
from pyPhotoAlbum.snapping import SnapResizeParams
|
||||
@ -80,7 +80,7 @@ class ElementManipulationMixin:
|
||||
resize_handle=self.resize_handle,
|
||||
page_size=page_size,
|
||||
dpi=dpi,
|
||||
project=main_window.project,
|
||||
project=main_window.project, # type: ignore[attr-defined]
|
||||
)
|
||||
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:
|
||||
return
|
||||
if self.selected_element is None:
|
||||
return
|
||||
|
||||
start_x, start_y = self.resize_start_pos
|
||||
start_w, start_h = self.resize_start_size
|
||||
|
||||
@ -14,7 +14,7 @@ class ElementSelectionMixin:
|
||||
# Type hints for expected attributes from mixing class
|
||||
_page_renderers: list
|
||||
|
||||
def window(self) -> "QMainWindow":
|
||||
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
@ -86,7 +86,7 @@ class ElementSelectionMixin:
|
||||
# Store the renderer with the element for later use
|
||||
element._page_renderer = renderer # type: ignore[attr-defined]
|
||||
element._parent_page = page # type: ignore[attr-defined]
|
||||
return element
|
||||
return element # type: ignore[no-any-return]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@ -123,7 +123,7 @@ class InteractionState:
|
||||
Returns:
|
||||
Dict with non-None state values
|
||||
"""
|
||||
state = {}
|
||||
state: Dict[str, Any] = {}
|
||||
if self.position is not None:
|
||||
state["position"] = self.position
|
||||
if self.size is not None:
|
||||
|
||||
@ -3,9 +3,14 @@ Mouse interaction mixin for GLWidget - coordinates all mouse events
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Optional, Set
|
||||
|
||||
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:
|
||||
@ -16,6 +21,39 @@ class MouseInteractionMixin:
|
||||
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):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@ -27,6 +65,7 @@ class MouseInteractionMixin:
|
||||
|
||||
def _handle_rotation_start(self, x: float, y: float):
|
||||
"""Start rotation interaction for selected element."""
|
||||
assert self.selected_element is not None
|
||||
self._begin_rotate(self.selected_element)
|
||||
self.drag_start_pos = (x, y)
|
||||
self.rotation_start_angle = self.selected_element.rotation
|
||||
@ -34,6 +73,7 @@ class MouseInteractionMixin:
|
||||
|
||||
def _handle_resize_start(self, x: float, y: float, handle):
|
||||
"""Start resize interaction for selected element."""
|
||||
assert self.selected_element is not None
|
||||
self._begin_resize(self.selected_element)
|
||||
self.resize_handle = handle
|
||||
self.drag_start_pos = (x, y)
|
||||
@ -139,10 +179,10 @@ class MouseInteractionMixin:
|
||||
|
||||
def _handle_rotation_move(self, x: float, y: float):
|
||||
"""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
|
||||
|
||||
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_w, elem_h = self.selected_element.size
|
||||
|
||||
@ -174,10 +214,11 @@ class MouseInteractionMixin:
|
||||
|
||||
def _handle_element_move(self, x: float, y: float):
|
||||
"""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)
|
||||
|
||||
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:
|
||||
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_y = self.drag_start_element_pos[1] + total_dy
|
||||
|
||||
assert self.selected_element is not None
|
||||
main_window = self.window()
|
||||
snap_sys = page.layout.snapping_system
|
||||
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(
|
||||
position=(new_x, new_y),
|
||||
size=self.selected_element.size,
|
||||
page_size=page_size,
|
||||
dpi=dpi,
|
||||
project=main_window.project,
|
||||
project=main_window.project, # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
self.selected_element.position = snapped_pos
|
||||
|
||||
@ -23,15 +23,15 @@ class AlignmentOperationsMixin:
|
||||
status_msg: Status message format string (will receive element count)
|
||||
"""
|
||||
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
|
||||
|
||||
changes = alignment_func(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(status_msg.format(len(elements)), 2000)
|
||||
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
|
||||
|
||||
@ribbon_action(
|
||||
label="Align Left",
|
||||
|
||||
@ -23,15 +23,15 @@ class DistributionOperationsMixin:
|
||||
status_msg: Status message format string (will receive element count)
|
||||
"""
|
||||
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
|
||||
|
||||
changes = distribution_func(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(status_msg.format(len(elements)), 2000)
|
||||
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
|
||||
|
||||
@ribbon_action(
|
||||
label="Distribute H",
|
||||
|
||||
@ -301,7 +301,7 @@ class FileOperationsMixin:
|
||||
|
||||
if not file_path:
|
||||
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:
|
||||
@ -319,7 +319,7 @@ class FileOperationsMixin:
|
||||
|
||||
# Bridge object: signals are thread-safe so background thread can
|
||||
# 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):
|
||||
if hasattr(self, "_loading_widget"):
|
||||
@ -344,7 +344,7 @@ class FileOperationsMixin:
|
||||
print(f"Successfully saved project to: {file_path}")
|
||||
if getattr(self, "_pending_close", False):
|
||||
self._pending_close = False
|
||||
self.close()
|
||||
self.close() # type: ignore[attr-defined]
|
||||
else:
|
||||
self._pending_close = False
|
||||
error_msg = f"Failed to save project: {error}"
|
||||
@ -419,7 +419,9 @@ class FileOperationsMixin:
|
||||
"then click 'Attempt Healing' to find and import them."
|
||||
)
|
||||
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()
|
||||
if result == QMessageBox.StandardButton.Open:
|
||||
|
||||
@ -23,15 +23,15 @@ class SizeOperationsMixin:
|
||||
status_msg: Status message format string (will receive element count)
|
||||
"""
|
||||
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
|
||||
|
||||
changes = resize_func(elements)
|
||||
if changes:
|
||||
cmd = ResizeElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(status_msg.format(len(elements)), 2000)
|
||||
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
page = self.get_current_page()
|
||||
page = self.get_current_page() # type: ignore[attr-defined]
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
if change:
|
||||
cmd = ResizeElementsCommand([change])
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(status_msg, 2000)
|
||||
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(status_msg, 2000) # type: ignore[attr-defined]
|
||||
|
||||
@ribbon_action(
|
||||
label="Same Size",
|
||||
|
||||
@ -31,7 +31,7 @@ class StyleOperationsMixin:
|
||||
"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
# Store old styles for undo
|
||||
@ -86,10 +86,10 @@ class StyleOperationsMixin:
|
||||
return None
|
||||
|
||||
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.show_status(f"{description} applied to {len(images)} image(s)", 2000)
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(f"{description} applied to {len(images)} image(s)", 2000) # type: ignore[attr-defined]
|
||||
|
||||
# =========================================================================
|
||||
# Corner Radius
|
||||
@ -106,7 +106,7 @@ class StyleOperationsMixin:
|
||||
"""Show dialog to set corner radius"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
|
||||
@ -151,7 +151,7 @@ class StyleOperationsMixin:
|
||||
"""Show dialog to configure border"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
|
||||
@ -198,7 +198,7 @@ class StyleOperationsMixin:
|
||||
"""Show dialog to configure drop shadow"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
|
||||
@ -235,7 +235,7 @@ class StyleOperationsMixin:
|
||||
"""Toggle shadow enabled/disabled"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
# Toggle based on first selected image
|
||||
@ -330,7 +330,7 @@ class StyleOperationsMixin:
|
||||
"""Show dialog to select decorative frame"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog
|
||||
|
||||
@ -13,11 +13,11 @@ class PageNavigationMixin:
|
||||
pan_offset: Tuple[float, float]
|
||||
zoom_level: float
|
||||
|
||||
def update(self) -> None:
|
||||
def update(self) -> None: # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
def window(self) -> "QMainWindow":
|
||||
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
|
||||
@ -181,7 +181,7 @@ class ViewportMixin:
|
||||
content_height = bounds["height"]
|
||||
|
||||
# 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:
|
||||
max_pan_up = 0 # Can't pan beyond top edge
|
||||
|
||||
@ -103,15 +103,15 @@ class ImageStyle:
|
||||
"""
|
||||
self.corner_radius = corner_radius
|
||||
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_offset = tuple(shadow_offset)
|
||||
self.shadow_offset: Tuple[float, float] = shadow_offset
|
||||
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_color = tuple(frame_color)
|
||||
self.frame_color: Tuple[int, int, int] = frame_color
|
||||
# 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":
|
||||
"""Create a copy of this style."""
|
||||
@ -148,7 +148,7 @@ class ImageStyle:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: Dict[str, Any]) -> "ImageStyle":
|
||||
def deserialize(cls, data: Optional[Dict[str, Any]]) -> "ImageStyle":
|
||||
"""Deserialize style from dictionary."""
|
||||
if data is None:
|
||||
return cls()
|
||||
@ -526,7 +526,7 @@ class ImageData(BaseLayoutElement):
|
||||
|
||||
frame_manager = get_frame_manager()
|
||||
frame_manager.render_frame_opengl(
|
||||
frame_name=self.style.frame_style,
|
||||
frame_name=self.style.frame_style, # type: ignore[arg-type]
|
||||
x=x,
|
||||
y=y,
|
||||
width=w,
|
||||
|
||||
@ -121,7 +121,7 @@ class PageLayout:
|
||||
if hasattr(self, "_async_loader"):
|
||||
loader = self._async_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:
|
||||
loader = None
|
||||
|
||||
@ -131,7 +131,7 @@ class PageLayout:
|
||||
|
||||
# Determine priority based on visibility (HIGH for now, can be refined)
|
||||
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_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:
|
||||
img = Image.open(task.image_path)
|
||||
img: Image.Image = Image.open(task.image_path)
|
||||
except Exception as open_err:
|
||||
import traceback
|
||||
|
||||
@ -389,7 +389,7 @@ class PDFExporter:
|
||||
Scans all pages and elements to build a list of ImageTask objects
|
||||
that can be processed in parallel.
|
||||
"""
|
||||
tasks = []
|
||||
tasks: List[ImageTask] = []
|
||||
dpi = self.project.working_dpi
|
||||
|
||||
for page in self.project.pages:
|
||||
@ -580,7 +580,7 @@ class PDFExporter:
|
||||
img.load()
|
||||
# Store both image and buffer reference to prevent garbage collection
|
||||
# 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
|
||||
|
||||
except Exception as e:
|
||||
@ -760,7 +760,7 @@ class PDFExporter:
|
||||
y_pt=y_pt,
|
||||
width_pt=width_pt,
|
||||
height_pt=height_pt,
|
||||
page_number=page_number,
|
||||
page_number=int(page_number),
|
||||
)
|
||||
self._render_image(ctx)
|
||||
elif isinstance(element, TextBoxData):
|
||||
@ -943,7 +943,7 @@ class PDFExporter:
|
||||
|
||||
try:
|
||||
# Load image using resolved path
|
||||
img = Image.open(image_full_path)
|
||||
img: Image.Image = Image.open(image_full_path)
|
||||
img = convert_to_rgba(img)
|
||||
|
||||
# Apply PIL-level rotation if needed
|
||||
@ -1072,7 +1072,7 @@ class PDFExporter:
|
||||
|
||||
frame_manager.render_frame_pdf(
|
||||
canvas=ctx.canvas,
|
||||
frame_name=style.frame_style,
|
||||
frame_name=style.frame_style, # type: ignore[arg-type]
|
||||
x_pt=ctx.x_pt,
|
||||
y_pt=ctx.y_pt,
|
||||
width_pt=ctx.width_pt,
|
||||
|
||||
@ -342,7 +342,7 @@ class Project:
|
||||
"""
|
||||
from pyPhotoAlbum.models import GhostPageData
|
||||
|
||||
layout = []
|
||||
layout: list[tuple[str, Optional["Page"], int]] = []
|
||||
current_position = 1 # Start at position 1 (right page)
|
||||
|
||||
for page in self.pages:
|
||||
|
||||
@ -317,7 +317,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
||||
self.current_folder = folder_path
|
||||
|
||||
# Find all image files
|
||||
self.image_files = []
|
||||
self.image_files: list[Path] = []
|
||||
for ext in IMAGE_EXTENSIONS:
|
||||
self.image_files.extend(folder_path.glob(f"*{ext}"))
|
||||
self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user