feat: improve LED Mapper settings and image support
- Добавлена сохраняемая настройка размера текста индексов LED с мгновенным предпросмотром и сбросом. - Исправлено размещение быстрых кнопок в строке меню без перекрытия пунктов на узком окне. - Усилена проверка OTA-загрузок: права записи, конечный URL, авторизация, Content-Length и отдельная группа updater-процесса. - Добавлен единый загрузчик подложек с определением формата по содержимому, EXIF-ориентацией и преобразованием CMYK. - Расширен список форматов подложки и добавлена безопасная обработка крупных изображений. - Ошибки декодирования теперь показываются пользователю без замены текущей подложки. - Обновлены smoke-тесты, документация, PyInstaller-конфигурация и версия приложения до 3.4.0.
This commit is contained in:
parent
37fbb2c545
commit
25ca2101a4
@ -94,6 +94,7 @@ a = Analysis(
|
||||
datas=[(str(spec_dir / 'assets'), 'assets'), (str(build_version_path), '.')],
|
||||
hiddenimports=[
|
||||
'canvas',
|
||||
'background_image_loader',
|
||||
'background_dialog',
|
||||
'icons',
|
||||
'localization',
|
||||
|
||||
@ -18,7 +18,7 @@ LED Mapper — настольное приложение на Python и PySide6
|
||||
- Продолжение существующего блока кистью от последней точки блока.
|
||||
- Предпросмотр зоны действия кисти и точек, которые будут добавлены.
|
||||
- Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации.
|
||||
- Настраиваемый абсолютный диаметр LED-точек: визуальный размер не меняется при приближении.
|
||||
- Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении.
|
||||
- Undo/redo для операций разметки.
|
||||
- Удаление назначений через `Del` и контекстные меню.
|
||||
- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo.
|
||||
@ -89,7 +89,7 @@ python -m venv .venv
|
||||
6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED.
|
||||
7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`.
|
||||
|
||||
Шаг сетки и диаметр точек меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек — в пользовательских параметрах приложения. Изменение шага не перемещает уже существующие LED автоматически.
|
||||
Шаг сетки, диаметр точек и размер текста индексов меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Размер текста индексов обновляется на схеме сразу во время настройки; при отмене возвращается прежнее значение. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек и размер текста индексов — в пользовательских параметрах приложения. Визуальные размеры точек и подписей не меняются при приближении схемы. Изменение шага не перемещает уже существующие LED автоматически.
|
||||
|
||||
Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo.
|
||||
|
||||
@ -97,6 +97,8 @@ python -m venv .venv
|
||||
|
||||
В собранном Windows EXE приложение проверяет наличие нового стабильного релиза после запуска. В меню `Справка` можно запустить проверку вручную и, если новая версия найдена, выбрать `Установить обновление`. Загружается только артефакт вида `LEDMapper-vX.Y.Z.exe` из репозитория `Robotail/LED-mapper` на `git.robotail.pro`. После проверки размера и формата EXE приложение предлагает сохранить изменённую схему, закрывается, а отдельный процесс заменяет EXE по прежнему пути и запускает новую версию — существующие ярлыки продолжают работать. Версия релизной сборки хранится внутри EXE, поэтому переименование файла не вызывает повторную установку того же обновления. При неудачном запуске старая версия восстанавливается; причина записывается рядом с EXE в `update-error.log`. Для обновления нужны доступ к серверу релизов и право записи в каталог приложения. Из исходников можно проверить наличие версии, но автоустановка недоступна.
|
||||
|
||||
Перед загрузкой проверяются права записи в каталог приложения. Перенаправления на страницу входа, другой сайт или неожиданный путь того же сервера отклоняются; объявленный сервером размер файла должен совпадать с размером артефакта в данных релиза.
|
||||
|
||||
Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование.
|
||||
|
||||
## Горячие клавиши
|
||||
@ -136,7 +138,7 @@ python -m venv .venv
|
||||
|
||||
JSON схемы хранит `coordinate_unit`, `grid_step_mm` и `index_base: 0`. При первом открытии старого файла без единицы координат приложение определяет её один раз и затем сохраняет явно. Старые схемы с 1-based ID линий и блоков при загрузке переводятся на 0-based; сохранение записывает маркер `index_base: 0`, чтобы повторная загрузка не сдвигала ID. Сохранение выполняется через временный файл и атомарную замену, поэтому исходный JSON не повреждается при ошибке записи.
|
||||
|
||||
Если у схемы есть подложка, при сохранении её исходное PNG- или JPEG-изображение встраивается в секцию `background.embedded_image` внутри JSON вместе с именем, MIME-типом и контрольной суммой. После этого схема не зависит от внешнего файла, а размеры и параметры подложки сохраняются при редактировании и переносе JSON. Для уже встроенного и не заменённого изображения диалог `Подложка` показывает в поле пути значение `Встроенная`. Чтобы заменить его, нажмите `Выбрать` и укажите новый файл; новое изображение будет встроено при следующем сохранении схемы.
|
||||
Если у схемы есть подложка, при сохранении исходное изображение встраивается в секцию `background.embedded_image` внутри JSON вместе с именем, MIME-типом и контрольной суммой. Диалог выбора показывает все форматы, которые доступны в поставляемой версии Qt (включая PNG, JPEG/JFIF, BMP, GIF, WebP, TIFF и SVG), распознаёт содержимое независимо от регистра и расширения файла и учитывает EXIF-ориентацию. Большие изображения безопасно уменьшаются для отображения. Если изображение повреждено или его декодер недоступен, приложение показывает причину и сохраняет прежнюю подложку. После сохранения схема не зависит от внешнего файла, а размеры и параметры подложки сохраняются при редактировании и переносе JSON. Для уже встроенного и не заменённого изображения диалог `Подложка` показывает в поле пути значение `Встроенная`. Чтобы заменить его, нажмите `Выбрать` и укажите новый файл; новое изображение будет встроено при следующем сохранении схемы.
|
||||
|
||||
В `vm_map.h` используются физические адреса из GUI:
|
||||
|
||||
|
||||
@ -16,9 +16,11 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
try:
|
||||
from .background_image_loader import supported_image_filter
|
||||
from .localization import localize_dialog_buttons
|
||||
from .models import BackgroundImage
|
||||
except ImportError:
|
||||
from background_image_loader import supported_image_filter
|
||||
from localization import localize_dialog_buttons
|
||||
from models import BackgroundImage
|
||||
|
||||
@ -34,7 +36,7 @@ class BackgroundDialog(QDialog):
|
||||
self._source_background = background
|
||||
self._keep_embedded_image = background.has_embedded_image()
|
||||
self.path = QLineEdit(self.EMBEDDED_PATH_LABEL if self._keep_embedded_image else background.path)
|
||||
self.path.setPlaceholderText("PNG или JPEG")
|
||||
self.path.setPlaceholderText("Файл изображения")
|
||||
self.path.setReadOnly(self._keep_embedded_image)
|
||||
if self._keep_embedded_image:
|
||||
source_name = background.embedded_image_name or "изображение"
|
||||
@ -97,13 +99,13 @@ class BackgroundDialog(QDialog):
|
||||
self.scale_y.setValue(100.0)
|
||||
|
||||
def _browse(self) -> None:
|
||||
start = self.path.text().strip()
|
||||
start = "" if self._keep_embedded_image else self.path.text().strip()
|
||||
start_path = Path(start).parent if start else Path.home()
|
||||
selected, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Выбрать подложку",
|
||||
str(start_path),
|
||||
"Изображения (*.png *.jpg *.jpeg);;Все файлы (*.*)",
|
||||
supported_image_filter(),
|
||||
)
|
||||
if selected:
|
||||
self._set_external_path(selected)
|
||||
|
||||
147
background_image_loader.py
Normal file
147
background_image_loader.py
Normal file
@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QSize, Qt
|
||||
from PySide6.QtGui import QImage, QImageIOHandler, QImageReader
|
||||
|
||||
try:
|
||||
from .models import BackgroundImage
|
||||
except ImportError:
|
||||
from models import BackgroundImage
|
||||
|
||||
|
||||
MAX_BACKGROUND_IMAGE_SIDE = 8192
|
||||
MAX_BACKGROUND_IMAGE_PIXELS = 32_000_000
|
||||
MAX_UNSCALED_SOURCE_PIXELS = 100_000_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackgroundImageLoadResult:
|
||||
image: QImage
|
||||
error: str = ""
|
||||
|
||||
@property
|
||||
def loaded(self) -> bool:
|
||||
return not self.image.isNull()
|
||||
|
||||
|
||||
def supported_image_filter() -> str:
|
||||
formats = sorted(
|
||||
{
|
||||
bytes(image_format).decode("ascii", errors="ignore").lower()
|
||||
for image_format in QImageReader.supportedImageFormats()
|
||||
if image_format
|
||||
}
|
||||
)
|
||||
wildcards: list[str] = []
|
||||
for image_format in formats:
|
||||
if not image_format or image_format == "pdf":
|
||||
continue
|
||||
wildcards.extend((f"*.{image_format}", f"*.{image_format.upper()}"))
|
||||
patterns = " ".join(dict.fromkeys(wildcards))
|
||||
return f"Изображения ({patterns});;Все файлы (*.*)"
|
||||
|
||||
|
||||
def resolve_background_path(background: BackgroundImage, document_path: Path | None = None) -> Path:
|
||||
path = Path(background.path)
|
||||
if not path.is_absolute() and document_path is not None:
|
||||
path = document_path.parent / path
|
||||
return path
|
||||
|
||||
|
||||
def background_cache_key(background: BackgroundImage, document_path: Path | None = None) -> tuple[object, ...]:
|
||||
if background.has_embedded_image():
|
||||
image_bytes = background.embedded_image_bytes()
|
||||
return ("embedded", background.embedded_image_sha256 or hash(image_bytes), len(image_bytes))
|
||||
path = resolve_background_path(background, document_path)
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return ("file", str(path), None, None)
|
||||
return ("file", str(path), stat.st_mtime_ns, stat.st_size)
|
||||
|
||||
|
||||
def _bounded_image_size(size: QSize, max_side: int) -> QSize:
|
||||
if not size.isValid() or max_side <= 0:
|
||||
return size
|
||||
side_factor = min(1.0, max_side / max(size.width(), size.height()))
|
||||
pixel_factor = min(1.0, math.sqrt(MAX_BACKGROUND_IMAGE_PIXELS / (size.width() * size.height())))
|
||||
factor = min(side_factor, pixel_factor)
|
||||
if factor >= 1.0:
|
||||
return size
|
||||
return QSize(max(1, math.floor(size.width() * factor)), max(1, math.floor(size.height() * factor)))
|
||||
|
||||
|
||||
def load_background_image(
|
||||
background: BackgroundImage,
|
||||
document_path: Path | None = None,
|
||||
*,
|
||||
max_side: int = MAX_BACKGROUND_IMAGE_SIDE,
|
||||
) -> BackgroundImageLoadResult:
|
||||
buffer: QBuffer | None = None
|
||||
if background.has_embedded_image():
|
||||
image_bytes = background.embedded_image_bytes()
|
||||
if not image_bytes:
|
||||
return BackgroundImageLoadResult(QImage(), "Встроенные данные изображения повреждены или пусты.")
|
||||
buffer = QBuffer()
|
||||
buffer.setData(QByteArray(image_bytes))
|
||||
if not buffer.open(QIODevice.OpenModeFlag.ReadOnly):
|
||||
return BackgroundImageLoadResult(QImage(), "Не удалось открыть встроенные данные изображения.")
|
||||
reader = QImageReader(buffer)
|
||||
source_name = background.embedded_image_name or "встроенное изображение"
|
||||
else:
|
||||
path = resolve_background_path(background, document_path)
|
||||
if not path.is_file():
|
||||
return BackgroundImageLoadResult(QImage(), f"Файл не найден: {path}")
|
||||
reader = QImageReader(str(path))
|
||||
source_name = path.name
|
||||
|
||||
previous_allocation_limit: int | None = None
|
||||
try:
|
||||
reader.setAutoTransform(True)
|
||||
reader.setDecideFormatFromContent(True)
|
||||
size = reader.size()
|
||||
bounded_size = _bounded_image_size(size, max_side)
|
||||
if bounded_size != size:
|
||||
if not reader.supportsOption(QImageIOHandler.ImageOption.ScaledSize):
|
||||
source_pixels = size.width() * size.height()
|
||||
if source_pixels > MAX_UNSCALED_SOURCE_PIXELS:
|
||||
return BackgroundImageLoadResult(
|
||||
QImage(),
|
||||
f"Изображение «{source_name}» слишком большое для безопасного декодирования "
|
||||
f"({size.width()}×{size.height()} пикселей).",
|
||||
)
|
||||
current_allocation_limit = QImageReader.allocationLimit()
|
||||
required_megabytes = math.ceil(source_pixels * 8 / (1024 * 1024)) + 16
|
||||
if current_allocation_limit > 0 and required_megabytes > current_allocation_limit:
|
||||
previous_allocation_limit = current_allocation_limit
|
||||
QImageReader.setAllocationLimit(required_megabytes)
|
||||
reader.setScaledSize(bounded_size)
|
||||
image = reader.read()
|
||||
if image.isNull():
|
||||
details = reader.errorString().strip()
|
||||
message = f"Не удалось декодировать «{source_name}»"
|
||||
if details:
|
||||
message += f": {details}"
|
||||
return BackgroundImageLoadResult(QImage(), message + ".")
|
||||
|
||||
bounded_size = _bounded_image_size(image.size(), max_side)
|
||||
if bounded_size != image.size():
|
||||
image = image.scaled(
|
||||
bounded_size,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
if image.format() not in (QImage.Format.Format_ARGB32, QImage.Format.Format_ARGB32_Premultiplied):
|
||||
image = image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
|
||||
if image.isNull():
|
||||
return BackgroundImageLoadResult(QImage(), f"Не удалось преобразовать «{source_name}» для отображения.")
|
||||
return BackgroundImageLoadResult(image)
|
||||
finally:
|
||||
if previous_allocation_limit is not None:
|
||||
QImageReader.setAllocationLimit(previous_allocation_limit)
|
||||
if buffer is not None:
|
||||
buffer.close()
|
||||
65
canvas.py
65
canvas.py
@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QPointF, QRectF, QSize, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QColor, QBrush, QImage, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QColor, QBrush, QFont, QPainter, QPainterPath, QPen, QPixmap, QTransform
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QGraphicsEllipseItem,
|
||||
@ -22,12 +21,13 @@ from PySide6.QtWidgets import (
|
||||
from shiboken6 import isValid
|
||||
|
||||
try:
|
||||
from .background_image_loader import background_cache_key, load_background_image
|
||||
from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
||||
except ImportError:
|
||||
from background_image_loader import background_cache_key, load_background_image
|
||||
from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
||||
|
||||
|
||||
MAX_BACKGROUND_PIXMAP_SIDE = 8192
|
||||
MAX_GRID_LINES = 800
|
||||
|
||||
|
||||
@ -93,11 +93,13 @@ class LedCanvas(QGraphicsView):
|
||||
self._background_handles: dict[str, QGraphicsRectItem] = {}
|
||||
self._background_edit_mode = False
|
||||
self._background_drag: dict | None = None
|
||||
self._background_pixmap_cache_key: tuple[str, str | int | None] | None = None
|
||||
self._background_pixmap_cache_key: tuple[object, ...] | None = None
|
||||
self._background_pixmap_cache: QPixmap | None = None
|
||||
self._background_load_error = ""
|
||||
self._background_loaded = False
|
||||
self._direction_arrows_visible = True
|
||||
self._line_index_labels_visible = True
|
||||
self._line_index_font_size_px = 12
|
||||
self._point_radius = 4.5
|
||||
self._brush_radius = 18.0
|
||||
self._zoom_focus_pull = 0.35
|
||||
@ -238,6 +240,18 @@ class LedCanvas(QGraphicsView):
|
||||
def line_index_labels_visible(self) -> bool:
|
||||
return self._line_index_labels_visible
|
||||
|
||||
def set_line_index_font_size(self, font_size_px: int) -> None:
|
||||
font_size = max(1, min(256, int(font_size_px)))
|
||||
if self._line_index_font_size_px == font_size:
|
||||
return
|
||||
self._line_index_font_size_px = font_size
|
||||
for label in self.line_index_label_items.values():
|
||||
if label is not None and isValid(label):
|
||||
font = label.font()
|
||||
font.setPixelSize(font_size)
|
||||
label.setFont(font)
|
||||
self._position_line_index_labels()
|
||||
|
||||
def set_brush_radius(self, radius: int) -> None:
|
||||
self._brush_radius = float(max(4, min(80, radius)))
|
||||
if self._brush_zone_item is not None and isValid(self._brush_zone_item):
|
||||
@ -318,6 +332,15 @@ class LedCanvas(QGraphicsView):
|
||||
def background_loaded(self) -> bool:
|
||||
return self._background_loaded
|
||||
|
||||
def background_load_error(self) -> str:
|
||||
return self._background_load_error
|
||||
|
||||
def validate_background(self, background: BackgroundImage) -> bool:
|
||||
if not background.is_enabled():
|
||||
self._background_load_error = "Не выбран файл изображения подложки."
|
||||
return False
|
||||
return not self._load_background_pixmap(background).isNull()
|
||||
|
||||
def set_background(self, background: BackgroundImage) -> None:
|
||||
if self.document is None:
|
||||
return
|
||||
@ -553,32 +576,13 @@ class LedCanvas(QGraphicsView):
|
||||
self._background_loaded = False
|
||||
|
||||
def _load_background_pixmap(self, background: BackgroundImage) -> QPixmap:
|
||||
buffer: QBuffer | None = None
|
||||
if background.has_embedded_image():
|
||||
image_bytes = background.embedded_image_bytes()
|
||||
cache_key = ("embedded", background.embedded_image_sha256 or len(image_bytes))
|
||||
buffer = QBuffer()
|
||||
buffer.setData(QByteArray(image_bytes))
|
||||
buffer.open(QIODevice.OpenModeFlag.ReadOnly)
|
||||
reader = QImageReader(buffer)
|
||||
else:
|
||||
path = Path(background.path)
|
||||
if not path.is_absolute() and self.document is not None and self.document.path is not None:
|
||||
path = self.document.path.parent / path
|
||||
cache_key = (str(path), path.stat().st_mtime_ns if path.exists() else None)
|
||||
reader = QImageReader(str(path))
|
||||
document_path = self.document.path if self.document is not None else None
|
||||
cache_key = background_cache_key(background, document_path)
|
||||
if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None:
|
||||
if buffer is not None:
|
||||
buffer.close()
|
||||
return self._background_pixmap_cache
|
||||
size = reader.size()
|
||||
if size.isValid() and max(size.width(), size.height()) > MAX_BACKGROUND_PIXMAP_SIDE:
|
||||
factor = MAX_BACKGROUND_PIXMAP_SIDE / max(size.width(), size.height())
|
||||
reader.setScaledSize(QSize(max(1, int(size.width() * factor)), max(1, int(size.height() * factor))))
|
||||
image = reader.read()
|
||||
if buffer is not None:
|
||||
buffer.close()
|
||||
pixmap = QPixmap.fromImage(image) if not image.isNull() else QPixmap()
|
||||
result = load_background_image(background, document_path)
|
||||
self._background_load_error = result.error
|
||||
pixmap = QPixmap.fromImage(result.image) if result.loaded else QPixmap()
|
||||
self._background_pixmap_cache_key = cache_key
|
||||
self._background_pixmap_cache = pixmap
|
||||
return pixmap
|
||||
@ -1404,6 +1408,9 @@ class LedCanvas(QGraphicsView):
|
||||
if item is None or not isValid(item):
|
||||
continue
|
||||
label = QGraphicsSimpleTextItem(str(point.line_index))
|
||||
font = QFont(label.font())
|
||||
font.setPixelSize(self._line_index_font_size_px)
|
||||
label.setFont(font)
|
||||
label.setBrush(QColor("#f8fafc" if self._theme == "dark" else "#111827"))
|
||||
label.setZValue(40)
|
||||
label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
|
||||
|
||||
@ -5,7 +5,7 @@ import sys
|
||||
from threading import Event, Thread
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QSignalBlocker, QSize, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QAction, QColor, QImageReader, QKeySequence
|
||||
from PySide6.QtGui import QAction, QColor, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QAbstractItemView,
|
||||
@ -603,15 +603,23 @@ class MainWindow(QMainWindow):
|
||||
if not hasattr(self, "menu_buttons_widget") or self.menu_buttons_widget is None:
|
||||
return
|
||||
menu_bar = self.menuBar()
|
||||
actions = menu_bar.actions()
|
||||
if len(actions) < 4:
|
||||
action_rects = [
|
||||
menu_bar.actionGeometry(action)
|
||||
for action in menu_bar.actions()
|
||||
if action.isVisible()
|
||||
]
|
||||
action_rects = [rect for rect in action_rects if rect.isValid() and rect.right() > 0]
|
||||
if not action_rects:
|
||||
self.menu_buttons_widget.hide()
|
||||
return
|
||||
tools_action = actions[3]
|
||||
rect = menu_bar.actionGeometry(tools_action)
|
||||
if rect.isValid() and rect.right() > 0:
|
||||
h = menu_bar.height()
|
||||
w = self.menu_buttons_widget.sizeHint().width()
|
||||
self.menu_buttons_widget.setGeometry(rect.right() + 10, (h - 20) // 2, w, 20)
|
||||
left = max(rect.right() for rect in action_rects) + 10
|
||||
height = menu_bar.height()
|
||||
width = self.menu_buttons_widget.sizeHint().width()
|
||||
if left + width > menu_bar.contentsRect().right() + 1:
|
||||
self.menu_buttons_widget.hide()
|
||||
return
|
||||
self.menu_buttons_widget.setGeometry(left, max(0, (height - 20) // 2), width, 20)
|
||||
self.menu_buttons_widget.show()
|
||||
|
||||
def _build_layout(self) -> None:
|
||||
side = QWidget()
|
||||
@ -1026,14 +1034,10 @@ class MainWindow(QMainWindow):
|
||||
background = dialog.background()
|
||||
if not background.is_enabled():
|
||||
return
|
||||
if not background.has_embedded_image():
|
||||
image_path = Path(background.path)
|
||||
if not image_path.is_absolute() and self.document.path is not None:
|
||||
image_path = self.document.path.parent / image_path
|
||||
reader = QImageReader(str(image_path))
|
||||
if not reader.canRead():
|
||||
show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.")
|
||||
return
|
||||
if not self.canvas.validate_background(background):
|
||||
details = self.canvas.background_load_error() or "Формат или содержимое изображения не поддерживается."
|
||||
show_warning(self, "Подложка не загружена", details)
|
||||
return
|
||||
if background.to_payload() == self.document.background.to_payload():
|
||||
self.status.setText("Подложка не изменилась.")
|
||||
return
|
||||
@ -1118,12 +1122,15 @@ class MainWindow(QMainWindow):
|
||||
dialog_settings = deepcopy(self.settings)
|
||||
dialog_settings.grid_step_mm = self.document.grid_step_mm
|
||||
dialog = SettingsDialog(dialog_settings, self)
|
||||
previous_index_font_size = self.settings.led_index_font_size_px
|
||||
dialog.led_index_font_size.valueChanged.connect(self.canvas.set_line_index_font_size)
|
||||
self._disable_shortcuts()
|
||||
try:
|
||||
res = dialog.exec()
|
||||
finally:
|
||||
self._enable_shortcuts()
|
||||
if res != SettingsDialog.Accepted:
|
||||
self.canvas.set_line_index_font_size(previous_index_font_size)
|
||||
return
|
||||
new_settings = dialog.settings()
|
||||
grid_changed = abs(self.document.grid_step_mm - new_settings.grid_step_mm) > 1e-9
|
||||
@ -1168,6 +1175,7 @@ class MainWindow(QMainWindow):
|
||||
self.canvas.set_grid_visible(self.settings.show_grid_by_default)
|
||||
self.canvas.set_direction_arrows_visible(self.settings.show_direction_arrows_by_default)
|
||||
self.canvas.set_line_index_labels_visible(self.settings.show_led_ids_by_default)
|
||||
self.canvas.set_line_index_font_size(self.settings.led_index_font_size_px)
|
||||
self.canvas.set_brush_radius(self.settings.brush_radius)
|
||||
self.canvas.set_grid_step_mm(self.document.grid_step_mm)
|
||||
self.canvas.set_point_diameter(self.settings.led_point_diameter_px)
|
||||
|
||||
222
qa_smoke.py
222
qa_smoke.py
@ -31,7 +31,9 @@ from app.models import (
|
||||
order_points_like_grid_svg,
|
||||
)
|
||||
from app.panel_mapping_generator import generate_panel_mapping_from_document
|
||||
from app.background_image_loader import MAX_BACKGROUND_IMAGE_PIXELS, _bounded_image_size, load_background_image, supported_image_filter
|
||||
from app.update_service import UpdateError, check_for_update, download_update
|
||||
import app.settings as settings_module
|
||||
import app.update_service as updater
|
||||
from app.unfolding import MAX_LINES
|
||||
try:
|
||||
@ -44,8 +46,8 @@ try:
|
||||
from app.settings import DEFAULT_SHORTCUTS, AppSettings
|
||||
from app.settings_dialog import SettingsDialog
|
||||
from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet
|
||||
from PySide6.QtCore import QPoint, QPointF, QRectF, Qt
|
||||
from PySide6.QtGui import QColor, QImage, QKeySequence
|
||||
from PySide6.QtCore import QPoint, QPointF, QRectF, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QImage, QImageIOHandler, QImageWriter, QKeySequence
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QLineEdit, QMessageBox, QToolBar
|
||||
except ImportError:
|
||||
@ -71,9 +73,12 @@ except ImportError:
|
||||
QPoint = None
|
||||
QPointF = None
|
||||
QRectF = None
|
||||
QSize = None
|
||||
Qt = None
|
||||
QColor = None
|
||||
QImage = None
|
||||
QImageIOHandler = None
|
||||
QImageWriter = None
|
||||
QKeySequence = None
|
||||
QApplication = None
|
||||
QDialogButtonBox = None
|
||||
@ -145,6 +150,7 @@ def main() -> None:
|
||||
test_uid_point_id_mode_does_not_fallback_to_source_ids,
|
||||
test_panel_mapping_generation_from_document,
|
||||
test_update_release_validation_and_download,
|
||||
test_update_download_redirects_permissions_and_cancellation,
|
||||
test_update_install_keeps_executable_path_and_bundled_version,
|
||||
test_ordered_json_meter_coordinates_reload_as_mm,
|
||||
test_legacy_cleanup_waits_for_success,
|
||||
@ -164,6 +170,9 @@ def main() -> None:
|
||||
test_background_embedding_failure_keeps_document_and_target_unchanged,
|
||||
test_background_dialog_preserves_non_uniform_scale,
|
||||
test_background_dialog_marks_and_preserves_embedded_image,
|
||||
test_background_loader_supports_content_detection_and_exif_orientation,
|
||||
test_background_dialog_filter_uses_available_qt_formats,
|
||||
test_canvas_background_decode_failure_is_reported,
|
||||
test_canvas_background_layer_and_geometry,
|
||||
test_canvas_background_zero_offset_uses_scheme_center,
|
||||
test_canvas_background_edit_move_and_resize_helpers,
|
||||
@ -175,6 +184,7 @@ def main() -> None:
|
||||
test_canvas_background_changes_preserve_camera,
|
||||
test_canvas_uses_grid_svg_coordinates,
|
||||
test_canvas_shows_selected_line_index_labels,
|
||||
test_led_index_font_size_keeps_absolute_screen_size,
|
||||
test_canvas_rebuild_can_preserve_user_zoom,
|
||||
test_grid_toggle_adds_grid_items,
|
||||
test_edit_mode_keeps_regular_grid_spacing,
|
||||
@ -202,6 +212,7 @@ def main() -> None:
|
||||
test_main_window_direction_arrow_action_toggles_canvas,
|
||||
test_settings_apply_to_canvas_and_generation,
|
||||
test_settings_dialog_has_requested_categories,
|
||||
test_app_settings_persist_led_index_font_size,
|
||||
test_theme_resolution_and_canvas_apply,
|
||||
test_menu_disabled_items_are_visually_distinct,
|
||||
test_shortcut_settings_are_editable_and_layout_independent,
|
||||
@ -1291,9 +1302,12 @@ def test_panel_mapping_generation_from_document() -> None:
|
||||
|
||||
def test_update_release_validation_and_download() -> None:
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __init__(self, data: bytes, url: str) -> None:
|
||||
def __init__(self, data: bytes, url: str, content_length: int | None = None) -> None:
|
||||
super().__init__(data)
|
||||
self.url = url
|
||||
self.headers = {}
|
||||
if content_length is not None:
|
||||
self.headers["Content-Length"] = str(content_length)
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self.url
|
||||
@ -1337,6 +1351,76 @@ def test_update_release_validation_and_download() -> None:
|
||||
raise AssertionError("An update on another host must be rejected")
|
||||
|
||||
|
||||
def test_update_download_redirects_permissions_and_cancellation() -> None:
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __init__(self, data: bytes, url: str, content_length: int | None = None) -> None:
|
||||
super().__init__(data)
|
||||
self.url = url
|
||||
self.headers = {}
|
||||
if content_length is not None:
|
||||
self.headers["Content-Length"] = str(content_length)
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self.url
|
||||
|
||||
tag = "v3.2.1"
|
||||
name = f"LEDMapper-{tag}.exe"
|
||||
url = f"https://git.robotail.pro/Robotail/LED-mapper/releases/download/{tag}/{name}"
|
||||
payload = bytearray(256)
|
||||
payload[:2] = b"MZ"
|
||||
payload[0x3C:0x40] = (128).to_bytes(4, "little")
|
||||
payload[128:134] = b"PE\x00\x00\x64\x86"
|
||||
info = updater.UpdateInfo(tag, "3.2.1", name, url, len(payload))
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
directory = Path(temp_dir)
|
||||
for redirect in (
|
||||
"https://git.robotail.pro/user/login",
|
||||
"https://git.robotail.pro/Robotail/LED-mapper/releases/download/unexpected/update.exe",
|
||||
):
|
||||
try:
|
||||
download_update(
|
||||
info,
|
||||
directory,
|
||||
opener=lambda _request, redirect=redirect, **_kwargs: FakeResponse(bytes(payload), redirect),
|
||||
)
|
||||
except UpdateError:
|
||||
assert not list(directory.glob(".ledmapper-update-*"))
|
||||
else:
|
||||
raise AssertionError("A same-host redirect outside the expected asset path must be rejected")
|
||||
|
||||
try:
|
||||
download_update(
|
||||
info,
|
||||
directory,
|
||||
opener=lambda _request, **_kwargs: FakeResponse(bytes(payload), url, len(payload) + 1),
|
||||
)
|
||||
except UpdateError:
|
||||
assert not list(directory.glob(".ledmapper-update-*"))
|
||||
else:
|
||||
raise AssertionError("A conflicting Content-Length must be rejected")
|
||||
|
||||
with patch.object(updater.os, "access", return_value=False):
|
||||
try:
|
||||
download_update(info, directory, opener=lambda _request, **_kwargs: FakeResponse(bytes(payload), url))
|
||||
except UpdateError as exc:
|
||||
assert "Нет прав" in str(exc)
|
||||
else:
|
||||
raise AssertionError("A read-only application directory must be rejected before download")
|
||||
|
||||
try:
|
||||
download_update(
|
||||
info,
|
||||
directory,
|
||||
opener=lambda _request, **_kwargs: FakeResponse(bytes(payload), url),
|
||||
cancelled=lambda: True,
|
||||
)
|
||||
except updater.UpdateCancelled:
|
||||
assert not list(directory.glob(".ledmapper-update-*"))
|
||||
else:
|
||||
raise AssertionError("A cancelled download must remove its partial file")
|
||||
|
||||
|
||||
def test_update_install_keeps_executable_path_and_bundled_version() -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
directory = Path(temp_dir)
|
||||
@ -1362,6 +1446,13 @@ def test_update_install_keeps_executable_path_and_bundled_version() -> None:
|
||||
updater.launch_update(staged, info, process_id=123)
|
||||
arguments = popen.call_args.args[0]
|
||||
assert arguments[8] == arguments[9] == str(old_executable)
|
||||
creation_flags = popen.call_args.kwargs["creationflags"]
|
||||
assert creation_flags & getattr(updater.subprocess, "CREATE_NO_WINDOW", 0) == getattr(
|
||||
updater.subprocess, "CREATE_NO_WINDOW", 0
|
||||
)
|
||||
assert creation_flags & getattr(updater.subprocess, "CREATE_NEW_PROCESS_GROUP", 0) == getattr(
|
||||
updater.subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
||||
)
|
||||
helper = Path(arguments[5])
|
||||
script = helper.read_text(encoding="utf-8-sig")
|
||||
assert "Wait-Process" in script and "$moveDeadline" in script
|
||||
@ -1737,6 +1828,8 @@ def test_menu_bar_tools_buttons() -> None:
|
||||
return
|
||||
app = QApplication.instance() or QApplication([])
|
||||
window = MainWindow()
|
||||
window.resize(900, 420)
|
||||
window.show()
|
||||
app.processEvents()
|
||||
|
||||
from PySide6.QtWidgets import QToolButton, QFrame
|
||||
@ -1760,6 +1853,22 @@ def test_menu_bar_tools_buttons() -> None:
|
||||
for key in expected_keys:
|
||||
assert window.actions[key] in button_actions
|
||||
|
||||
help_action = next(action for action in window.menuBar().actions() if action.text() == "Справка")
|
||||
help_rect = window.menuBar().actionGeometry(help_action)
|
||||
assert container.isVisible()
|
||||
assert container.geometry().left() >= help_rect.right() + 10
|
||||
|
||||
window.resize(593, 420)
|
||||
app.processEvents()
|
||||
if container.isVisible():
|
||||
help_rect = window.menuBar().actionGeometry(help_action)
|
||||
assert container.geometry().left() >= help_rect.right() + 10
|
||||
assert container.geometry().right() <= window.menuBar().contentsRect().right()
|
||||
|
||||
window.resize(360, 420)
|
||||
app.processEvents()
|
||||
assert not container.isVisible()
|
||||
|
||||
window.close()
|
||||
|
||||
|
||||
@ -2022,6 +2131,63 @@ def test_background_dialog_marks_and_preserves_embedded_image() -> None:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_background_loader_supports_content_detection_and_exif_orientation() -> None:
|
||||
if QImage is None or QImageWriter is None or QImageIOHandler is None:
|
||||
return
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
disguised_path = Path(temp_dir) / "background.unsupported-extension"
|
||||
image = QImage(13, 9, QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor("#254769"))
|
||||
assert image.save(str(disguised_path), "PNG")
|
||||
disguised = load_background_image(BackgroundImage(str(disguised_path)))
|
||||
assert disguised.loaded
|
||||
assert disguised.image.size() == image.size()
|
||||
|
||||
oriented_path = Path(temp_dir) / "oriented.jpg"
|
||||
writer = QImageWriter(str(oriented_path), b"jpeg")
|
||||
writer.setTransformation(QImageIOHandler.Transformation.TransformationRotate90)
|
||||
assert writer.write(image), writer.errorString()
|
||||
del writer
|
||||
oriented = load_background_image(BackgroundImage(str(oriented_path)))
|
||||
assert oriented.loaded
|
||||
assert oriented.image.width() == image.height()
|
||||
assert oriented.image.height() == image.width()
|
||||
|
||||
cmyk_path = Path(temp_dir) / "cmyk.jpg"
|
||||
cmyk_image = QImage(11, 8, QImage.Format.Format_CMYK8888)
|
||||
cmyk_image.fill(QColor("#cc3311"))
|
||||
assert cmyk_image.save(str(cmyk_path), "JPEG")
|
||||
cmyk = load_background_image(BackgroundImage(str(cmyk_path)))
|
||||
assert cmyk.loaded
|
||||
assert cmyk.image.format() == QImage.Format.Format_ARGB32_Premultiplied
|
||||
|
||||
|
||||
def test_background_dialog_filter_uses_available_qt_formats() -> None:
|
||||
image_filter = supported_image_filter()
|
||||
assert "*.png" in image_filter
|
||||
assert "*.PNG" in image_filter
|
||||
assert "*.jpg" in image_filter or "*.jpeg" in image_filter
|
||||
assert "Все файлы (*.*)" in image_filter
|
||||
|
||||
|
||||
def test_canvas_background_decode_failure_is_reported() -> None:
|
||||
if LedCanvas is None or QApplication is None:
|
||||
return
|
||||
app = QApplication.instance() or QApplication([])
|
||||
with temp_document_path() as document_path, tempfile.TemporaryDirectory() as temp_dir:
|
||||
invalid_path = Path(temp_dir) / "damaged.png"
|
||||
invalid_path.write_bytes(b"not-an-image")
|
||||
document = MappingDocument()
|
||||
document.load(document_path)
|
||||
canvas = LedCanvas()
|
||||
canvas.set_document(document)
|
||||
original_background = document.background
|
||||
assert not canvas.validate_background(BackgroundImage(str(invalid_path)))
|
||||
assert "Не удалось декодировать" in canvas.background_load_error()
|
||||
assert document.background is original_background
|
||||
canvas.close()
|
||||
|
||||
|
||||
def test_canvas_background_layer_and_geometry() -> None:
|
||||
if LedCanvas is None or QApplication is None or QImage is None:
|
||||
return
|
||||
@ -2150,6 +2316,12 @@ def test_canvas_background_large_image_downsamples_and_grid_degrades() -> None:
|
||||
assert len(canvas.grid_items) <= 802
|
||||
canvas.close()
|
||||
|
||||
if QSize is not None:
|
||||
bounded = _bounded_image_size(QSize(12000, 12000), 8192)
|
||||
assert bounded.width() <= 8192
|
||||
assert bounded.height() <= 8192
|
||||
assert bounded.width() * bounded.height() <= MAX_BACKGROUND_IMAGE_PIXELS
|
||||
|
||||
|
||||
def test_main_window_background_reset_noops_at_100() -> None:
|
||||
if MainWindow is None or QApplication is None or QImage is None:
|
||||
@ -2363,6 +2535,32 @@ def test_canvas_shows_selected_line_index_labels() -> None:
|
||||
canvas.close()
|
||||
|
||||
|
||||
def test_led_index_font_size_keeps_absolute_screen_size() -> None:
|
||||
if LedCanvas is None or QApplication is None:
|
||||
return
|
||||
app = QApplication.instance() or QApplication([])
|
||||
with temp_document_path() as path:
|
||||
doc = MappingDocument()
|
||||
doc.load(path)
|
||||
line = doc.add_line()
|
||||
doc.add_block(line.id, [0, 1])
|
||||
canvas = LedCanvas()
|
||||
canvas.set_document(doc)
|
||||
canvas.select_points([0])
|
||||
|
||||
canvas.set_line_index_font_size(22)
|
||||
label = canvas.line_index_label_items[0]
|
||||
initial_height = label.boundingRect().height()
|
||||
assert label.font().pixelSize() == 22
|
||||
assert label.flags() & QGraphicsItem.ItemIgnoresTransformations
|
||||
|
||||
canvas.scale(7.0, 7.0)
|
||||
canvas._position_line_index_labels()
|
||||
assert label.font().pixelSize() == 22
|
||||
assert abs(label.boundingRect().height() - initial_height) < 1e-9
|
||||
canvas.close()
|
||||
|
||||
|
||||
def test_canvas_rebuild_can_preserve_user_zoom() -> None:
|
||||
if LedCanvas is None or QApplication is None:
|
||||
return
|
||||
@ -3065,6 +3263,7 @@ def test_settings_apply_to_canvas_and_generation() -> None:
|
||||
window.settings.pan_mode = "space"
|
||||
window.settings.instant_space_pan = True
|
||||
window.settings.led_point_diameter_px = 17
|
||||
window.settings.led_index_font_size_px = 21
|
||||
window.document.grid_step_mm = 47.5
|
||||
window.settings.default_output_dir = temp_dir
|
||||
window.settings.confirm_partial_generation = False
|
||||
@ -3081,6 +3280,7 @@ def test_settings_apply_to_canvas_and_generation() -> None:
|
||||
assert window.canvas._brush_radius == 31
|
||||
assert window.canvas._grid_step == 47.5
|
||||
assert abs(window.canvas._point_radius - 8.5) < 1e-9
|
||||
assert window.canvas._line_index_font_size_px == 21
|
||||
assert abs(window.canvas._zoom_focus_pull - 0.12) < 0.001
|
||||
assert window.canvas._pan_mode == "space"
|
||||
assert window.canvas._instant_space_pan is True
|
||||
@ -3105,16 +3305,32 @@ def test_settings_dialog_has_requested_categories() -> None:
|
||||
assert dialog.instant_space_pan.isChecked() is False
|
||||
dialog.grid_step.setValue(50.0)
|
||||
dialog.led_point_diameter.setValue(17)
|
||||
dialog.led_index_font_size.setValue(21)
|
||||
updated = dialog.settings()
|
||||
assert updated.grid_step_mm == 50.0
|
||||
assert updated.led_point_diameter_px == 17
|
||||
assert updated.led_index_font_size_px == 21
|
||||
dialog.grid_step_reset.click()
|
||||
dialog.led_point_diameter_reset.click()
|
||||
dialog.led_index_font_size_reset.click()
|
||||
assert dialog.grid_step.value() == 34.0
|
||||
assert dialog.led_point_diameter.value() == 9
|
||||
assert dialog.led_index_font_size.value() == 12
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_app_settings_persist_led_index_font_size() -> None:
|
||||
if AppSettings is None:
|
||||
return
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "settings.json"
|
||||
with patch.object(settings_module, "settings_path", return_value=path):
|
||||
settings = AppSettings(led_index_font_size_px=23)
|
||||
settings.save()
|
||||
loaded = AppSettings.load()
|
||||
assert loaded.led_index_font_size_px == 23
|
||||
|
||||
|
||||
def test_theme_resolution_and_canvas_apply() -> None:
|
||||
if QApplication is None or MainWindow is None or effective_theme is None:
|
||||
return
|
||||
|
||||
12
settings.py
12
settings.py
@ -44,7 +44,7 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
|
||||
("brush", "Кисть", "B"),
|
||||
]
|
||||
DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS}
|
||||
SETTINGS_VERSION = 3
|
||||
SETTINGS_VERSION = 4
|
||||
SHORTCUT_DEFAULTS_MIGRATION_VERSION = 2
|
||||
DEFAULT_GRID_STEP_MM = 34.0
|
||||
MIN_GRID_STEP_MM = 1.0
|
||||
@ -52,6 +52,9 @@ MAX_GRID_STEP_MM = 1000.0
|
||||
DEFAULT_LED_POINT_DIAMETER_PX = 9
|
||||
MIN_LED_POINT_DIAMETER_PX = 1
|
||||
MAX_LED_POINT_DIAMETER_PX = 64
|
||||
DEFAULT_LED_INDEX_FONT_SIZE_PX = 12
|
||||
MIN_LED_INDEX_FONT_SIZE_PX = 6
|
||||
MAX_LED_INDEX_FONT_SIZE_PX = 64
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -63,6 +66,7 @@ class AppSettings:
|
||||
show_led_ids_by_default: bool = True
|
||||
grid_step_mm: float = DEFAULT_GRID_STEP_MM
|
||||
led_point_diameter_px: int = DEFAULT_LED_POINT_DIAMETER_PX
|
||||
led_index_font_size_px: int = DEFAULT_LED_INDEX_FONT_SIZE_PX
|
||||
zoom_focus_pull: int = 35
|
||||
pan_mode: str = "middle_space"
|
||||
instant_space_pan: bool = False
|
||||
@ -99,6 +103,12 @@ class AppSettings:
|
||||
MAX_LED_POINT_DIAMETER_PX,
|
||||
DEFAULT_LED_POINT_DIAMETER_PX,
|
||||
)
|
||||
settings.led_index_font_size_px = clamp_int(
|
||||
settings.led_index_font_size_px,
|
||||
MIN_LED_INDEX_FONT_SIZE_PX,
|
||||
MAX_LED_INDEX_FONT_SIZE_PX,
|
||||
DEFAULT_LED_INDEX_FONT_SIZE_PX,
|
||||
)
|
||||
settings.zoom_focus_pull = clamp_int(settings.zoom_focus_pull, 0, 100, cls.zoom_focus_pull)
|
||||
settings.brush_radius = clamp_int(settings.brush_radius, 4, 80, cls.brush_radius)
|
||||
if settings.theme not in {"system", "light", "dark"}:
|
||||
|
||||
@ -30,11 +30,14 @@ try:
|
||||
from .localization import localize_dialog_buttons, show_warning
|
||||
from .settings import (
|
||||
DEFAULT_GRID_STEP_MM,
|
||||
DEFAULT_LED_INDEX_FONT_SIZE_PX,
|
||||
DEFAULT_LED_POINT_DIAMETER_PX,
|
||||
DEFAULT_SHORTCUTS,
|
||||
MAX_GRID_STEP_MM,
|
||||
MAX_LED_INDEX_FONT_SIZE_PX,
|
||||
MAX_LED_POINT_DIAMETER_PX,
|
||||
MIN_GRID_STEP_MM,
|
||||
MIN_LED_INDEX_FONT_SIZE_PX,
|
||||
MIN_LED_POINT_DIAMETER_PX,
|
||||
SETTINGS_VERSION,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
@ -44,11 +47,14 @@ except ImportError:
|
||||
from localization import localize_dialog_buttons, show_warning
|
||||
from settings import (
|
||||
DEFAULT_GRID_STEP_MM,
|
||||
DEFAULT_LED_INDEX_FONT_SIZE_PX,
|
||||
DEFAULT_LED_POINT_DIAMETER_PX,
|
||||
DEFAULT_SHORTCUTS,
|
||||
MAX_GRID_STEP_MM,
|
||||
MAX_LED_INDEX_FONT_SIZE_PX,
|
||||
MAX_LED_POINT_DIAMETER_PX,
|
||||
MIN_GRID_STEP_MM,
|
||||
MIN_LED_INDEX_FONT_SIZE_PX,
|
||||
MIN_LED_POINT_DIAMETER_PX,
|
||||
SETTINGS_VERSION,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
@ -95,6 +101,17 @@ class SettingsDialog(QDialog):
|
||||
self.led_point_diameter_reset.clicked.connect(
|
||||
lambda: self.led_point_diameter.setValue(DEFAULT_LED_POINT_DIAMETER_PX)
|
||||
)
|
||||
self.led_index_font_size = QSpinBox()
|
||||
self.led_index_font_size.setRange(
|
||||
MIN_LED_INDEX_FONT_SIZE_PX,
|
||||
MAX_LED_INDEX_FONT_SIZE_PX,
|
||||
)
|
||||
self.led_index_font_size.setSuffix(" px")
|
||||
self.led_index_font_size.setValue(settings.led_index_font_size_px)
|
||||
self.led_index_font_size_reset = QPushButton("Сбросить")
|
||||
self.led_index_font_size_reset.clicked.connect(
|
||||
lambda: self.led_index_font_size.setValue(DEFAULT_LED_INDEX_FONT_SIZE_PX)
|
||||
)
|
||||
self.zoom_pull = QSpinBox()
|
||||
self.zoom_pull.setRange(0, 100)
|
||||
self.zoom_pull.setSuffix(" %")
|
||||
@ -159,6 +176,7 @@ class SettingsDialog(QDialog):
|
||||
show_led_ids_by_default=self.show_led_ids.isChecked(),
|
||||
grid_step_mm=self.grid_step.value(),
|
||||
led_point_diameter_px=self.led_point_diameter.value(),
|
||||
led_index_font_size_px=self.led_index_font_size.value(),
|
||||
zoom_focus_pull=self.zoom_pull.value(),
|
||||
pan_mode=str(self.pan_mode.currentData()),
|
||||
instant_space_pan=self.instant_space_pan.isChecked(),
|
||||
@ -180,6 +198,10 @@ class SettingsDialog(QDialog):
|
||||
"Диаметр LED-точек",
|
||||
self._setting_with_reset(self.led_point_diameter, self.led_point_diameter_reset),
|
||||
)
|
||||
layout.addRow(
|
||||
"Размер текста индексов LED",
|
||||
self._setting_with_reset(self.led_index_font_size, self.led_index_font_size_reset),
|
||||
)
|
||||
layout.addRow("Притяжение зума к курсору", self.zoom_pull)
|
||||
layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору."))
|
||||
return page
|
||||
|
||||
@ -164,10 +164,13 @@ def download_update(
|
||||
target_dir = directory.resolve()
|
||||
if not target_dir.is_dir():
|
||||
raise UpdateError("Каталог приложения не найден")
|
||||
if not os.access(target_dir, os.W_OK):
|
||||
raise UpdateError("Нет прав на запись в каталог приложения")
|
||||
if not _trusted_url(info.download_url) or urlparse(info.download_url).path != (
|
||||
f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
|
||||
):
|
||||
raise UpdateError("Недопустимый адрес загрузки обновления")
|
||||
expected_path = f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
|
||||
path: Path | None = None
|
||||
received = 0
|
||||
request = Request(info.download_url, headers={"User-Agent": "LEDMapper-Updater"})
|
||||
@ -175,6 +178,19 @@ def download_update(
|
||||
with (opener or urlopen)(request, timeout=timeout) as response:
|
||||
if not _trusted_url(response.geturl()):
|
||||
raise UpdateError("Загрузка перенаправлена на другой сайт")
|
||||
final_path = urlparse(response.geturl()).path
|
||||
if final_path == "/user/login":
|
||||
raise UpdateError("Release-артефакты недоступны без авторизации")
|
||||
if final_path != expected_path:
|
||||
raise UpdateError("Загрузка перенаправлена на неожиданный адрес")
|
||||
content_length = getattr(response, "headers", {}).get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared_size = int(content_length)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise UpdateError("Сервер вернул некорректный размер обновления") from exc
|
||||
if declared_size != info.size:
|
||||
raise UpdateError("Размер обновления не совпадает с данными релиза")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb", dir=target_dir, prefix=".ledmapper-update-", suffix=".download", delete=False
|
||||
) as output:
|
||||
@ -296,6 +312,8 @@ def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None
|
||||
source = downloaded.resolve()
|
||||
if platform.system() != "Windows":
|
||||
raise UpdateError("Установка обновления доступна только в Windows")
|
||||
if not os.access(old_target.parent, os.W_OK):
|
||||
raise UpdateError("Нет прав на замену приложения")
|
||||
if source.parent != old_target.parent or not source.is_file() or not source.name.startswith(".ledmapper-update-"):
|
||||
raise UpdateError("Скачанный файл должен находиться рядом с приложением")
|
||||
if not EXECUTABLE_RE.fullmatch(info.asset_name):
|
||||
@ -310,6 +328,10 @@ def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None
|
||||
ready_path = old_target.parent / f".ledmapper-ready-{token}"
|
||||
try:
|
||||
_write_windows_updater(script_path)
|
||||
creation_flags = (
|
||||
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
)
|
||||
subprocess.Popen(
|
||||
[
|
||||
"powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
|
||||
@ -317,7 +339,7 @@ def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None
|
||||
str(new_target), str(backup_path), str(ready_path),
|
||||
],
|
||||
close_fds=True,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
creationflags=creation_flags,
|
||||
)
|
||||
except (OSError, UpdateError) as exc:
|
||||
script_path.unlink(missing_ok=True)
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
"""Текущая версия приложения; обновляется при публикации релиза."""
|
||||
|
||||
__version__ = "3.3.1"
|
||||
__version__ = "3.4.0"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user