Compare commits

...

3 Commits

Author SHA1 Message Date
30ac54f77f feat: Release v3.5.0 with free-form text comments
- Добавлены свободные многострочные комментарии на схеме: создание через меню, перемещение мышью, редактирование и удаление.
- Реализовано хранение комментариев в JSON с устойчивыми идентификаторами, координатами и сохранением неизвестных полей.
- Все операции с комментариями включены в Undo/Redo; поддержаны схемы только с заметками и сохранение удаления последней заметки.
- Исправлены границы общего вида, неограниченный масштаб схемы с одной заметкой и завершение перетаскивания при панорамировании пробелом.
- Добавлены семь smoke-сценариев, проверки совместимости генератора и обновлено руководство пользователя.
- Версия приложения обновлена до 3.5.0.
2026-11-05 08:50:36 +03:00
25ca2101a4 feat: improve LED Mapper settings and image support
- Добавлена сохраняемая настройка размера текста индексов LED с мгновенным предпросмотром и сбросом.
- Исправлено размещение быстрых кнопок в строке меню без перекрытия пунктов на узком окне.
- Усилена проверка OTA-загрузок: права записи, конечный URL, авторизация, Content-Length и отдельная группа updater-процесса.
- Добавлен единый загрузчик подложек с определением формата по содержимому, EXIF-ориентацией и преобразованием CMYK.
- Расширен список форматов подложки и добавлена безопасная обработка крупных изображений.
- Ошибки декодирования теперь показываются пользователю без замены текущей подложки.
- Обновлены smoke-тесты, документация, PyInstaller-конфигурация и версия приложения до 3.4.0.
2026-10-02 09:42:24 +03:00
37fbb2c545 fix: isolate Windows build dependencies
- Ограничен поиск DLL при сборке каталогами Python, окружения и Windows.
- Добавлена проверка источников бинарных библиотек перед упаковкой.
- Добавлен режим --verify-bundle и регрессионный smoke-тест.
- Обновлены документация и версия приложения до 3.3.1.
2026-09-18 14:32:19 +03:00
14 changed files with 1143 additions and 80 deletions

View File

@ -1,6 +1,8 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import os
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@ -45,7 +47,41 @@ def git_build_ref():
return 'untagged' return 'untagged'
def _build_library_roots():
windows_root = Path(os.environ.get('SystemRoot', r'C:\Windows')).resolve()
return tuple(dict.fromkeys(
path.resolve() for path in (
Path(sys.executable).parent,
Path(sys.prefix),
Path(sys.base_prefix),
Path(sys.base_prefix) / 'DLLs',
windows_root / 'System32',
windows_root,
) if path.is_dir()
))
def _set_isolated_build_path(roots):
# Analysis searches PATH for DLLs. Unrelated tools may provide incompatible
# copies of the Windows runtime or ICU libraries there.
os.environ['PATH'] = os.pathsep.join(str(root) for root in roots)
def _check_binary_sources(binaries, roots):
foreign = [
(name, source)
for name, source, kind in binaries
if kind == 'BINARY'
and not any(Path(source).resolve().is_relative_to(root) for root in roots)
]
if foreign:
details = '\n'.join(f'{name}: {source}' for name, source in foreign[:10])
raise RuntimeError(f'Build contains DLLs outside Python and Windows:\n{details}')
app_name = f'LEDMapper-{git_build_ref()}' app_name = f'LEDMapper-{git_build_ref()}'
library_roots = _build_library_roots()
_set_isolated_build_path(library_roots)
build_version_path = spec_dir.parent / 'build' / 'ledmapper-build-version.txt' build_version_path = spec_dir.parent / 'build' / 'ledmapper-build-version.txt'
build_version_path.parent.mkdir(parents=True, exist_ok=True) build_version_path.parent.mkdir(parents=True, exist_ok=True)
build_version_path.write_text(app_name.removeprefix('LEDMapper-'), encoding='utf-8') build_version_path.write_text(app_name.removeprefix('LEDMapper-'), encoding='utf-8')
@ -58,6 +94,7 @@ a = Analysis(
datas=[(str(spec_dir / 'assets'), 'assets'), (str(build_version_path), '.')], datas=[(str(spec_dir / 'assets'), 'assets'), (str(build_version_path), '.')],
hiddenimports=[ hiddenimports=[
'canvas', 'canvas',
'background_image_loader',
'background_dialog', 'background_dialog',
'icons', 'icons',
'localization', 'localization',
@ -79,6 +116,7 @@ a = Analysis(
noarchive=False, noarchive=False,
optimize=0, optimize=0,
) )
_check_binary_sources(a.binaries, library_roots)
pyz = PYZ(a.pure) pyz = PYZ(a.pure)
exe = EXE( exe = EXE(

View File

@ -18,8 +18,9 @@ LED Mapper — настольное приложение на Python и PySide6
- Продолжение существующего блока кистью от последней точки блока. - Продолжение существующего блока кистью от последней точки блока.
- Предпросмотр зоны действия кисти и точек, которые будут добавлены. - Предпросмотр зоны действия кисти и точек, которые будут добавлены.
- Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации. - Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации.
- Настраиваемый абсолютный диаметр LED-точек: визуальный размер не меняется при приближении. - Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении.
- Undo/redo для операций разметки. - Свободные многострочные текстовые комментарии на схеме.
- Undo/redo для операций разметки и комментариев.
- Удаление назначений через `Del` и контекстные меню. - Удаление назначений через `Del` и контекстные меню.
- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo. - Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo.
- Сворачивание боковой панели. - Сворачивание боковой панели.
@ -89,7 +90,7 @@ python -m venv .venv
6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED. 6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED.
7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`. 7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`.
Шаг сетки и диаметр точек меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек — в пользовательских параметрах приложения. Изменение шага не перемещает уже существующие LED автоматически. Шаг сетки, диаметр точек и размер текста индексов меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Размер текста индексов обновляется на схеме сразу во время настройки; при отмене возвращается прежнее значение. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек и размер текста индексов — в пользовательских параметрах приложения. Визуальные размеры точек и подписей не меняются при приближении схемы. Изменение шага не перемещает уже существующие LED автоматически.
Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo. Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo.
@ -97,8 +98,20 @@ python -m venv .venv
В собранном Windows EXE приложение проверяет наличие нового стабильного релиза после запуска. В меню `Справка` можно запустить проверку вручную и, если новая версия найдена, выбрать `Установить обновление`. Загружается только артефакт вида `LEDMapper-vX.Y.Z.exe` из репозитория `Robotail/LED-mapper` на `git.robotail.pro`. После проверки размера и формата EXE приложение предлагает сохранить изменённую схему, закрывается, а отдельный процесс заменяет EXE по прежнему пути и запускает новую версию — существующие ярлыки продолжают работать. Версия релизной сборки хранится внутри EXE, поэтому переименование файла не вызывает повторную установку того же обновления. При неудачном запуске старая версия восстанавливается; причина записывается рядом с EXE в `update-error.log`. Для обновления нужны доступ к серверу релизов и право записи в каталог приложения. Из исходников можно проверить наличие версии, но автоустановка недоступна. В собранном Windows EXE приложение проверяет наличие нового стабильного релиза после запуска. В меню `Справка` можно запустить проверку вручную и, если новая версия найдена, выбрать `Установить обновление`. Загружается только артефакт вида `LEDMapper-vX.Y.Z.exe` из репозитория `Robotail/LED-mapper` на `git.robotail.pro`. После проверки размера и формата EXE приложение предлагает сохранить изменённую схему, закрывается, а отдельный процесс заменяет EXE по прежнему пути и запускает новую версию — существующие ярлыки продолжают работать. Версия релизной сборки хранится внутри EXE, поэтому переименование файла не вызывает повторную установку того же обновления. При неудачном запуске старая версия восстанавливается; причина записывается рядом с EXE в `update-error.log`. Для обновления нужны доступ к серверу релизов и право записи в каталог приложения. Из исходников можно проверить наличие версии, но автоустановка недоступна.
Перед загрузкой проверяются права записи в каталог приложения. Перенаправления на страницу входа, другой сайт или неожиданный путь того же сервера отклоняются; объявленный сервером размер файла должен совпадать с размером артефакта в данных релиза.
Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование. Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование.
## Текстовые комментарии на схеме
Чтобы разместить заметку, нажмите правой кнопкой мыши в нужном месте схемы и выберите `Добавить комментарий…`. Команда `Инструменты -> Добавить комментарий…` создаёт заметку в центре видимой области. Введите многострочный текст и подтвердите ввод.
Комментарий можно свободно перетаскивать левой кнопкой мыши в любом режиме. Двойной щелчок открывает редактирование текста; в контекстном меню комментария доступны изменение и удаление. Размер текста остаётся читаемым при изменении масштаба. Команда `Показать всё` учитывает комментарии, в том числе за пределами LED-схемы.
Добавление, изменение, перемещение и удаление поддерживают Undo/Redo. Отмена диалога и пустой текст не изменяют схему. Комментарии сохраняются в JSON в списке `comments`: `id`, `text`, `x`, `y`; координаты используют единицы схемы. Старые JSON без этого списка открываются без комментариев. Можно сохранить схему, содержащую только комментарии.
Комментарии не привязаны к линиям, блокам или LED и не влияют на физические адреса в `VM_MAPPINGS`. Экспорт изображения маппинга через меню `Файл` по-прежнему показывает LED и легенду; текстовые комментарии сохраняются в JSON и отображаются в редакторе.
## Горячие клавиши ## Горячие клавиши
| Действие | Клавиши | | Действие | Клавиши |
@ -136,7 +149,7 @@ python -m venv .venv
JSON схемы хранит `coordinate_unit`, `grid_step_mm` и `index_base: 0`. При первом открытии старого файла без единицы координат приложение определяет её один раз и затем сохраняет явно. Старые схемы с 1-based ID линий и блоков при загрузке переводятся на 0-based; сохранение записывает маркер `index_base: 0`, чтобы повторная загрузка не сдвигала ID. Сохранение выполняется через временный файл и атомарную замену, поэтому исходный JSON не повреждается при ошибке записи. 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: В `vm_map.h` используются физические адреса из GUI:
@ -184,6 +197,8 @@ JSON схемы хранит `coordinate_unit`, `grid_step_mm` и `index_base: 0
.\.venv\Scripts\python.exe -m PyInstaller --noconfirm --clean app\LEDMapper.spec .\.venv\Scripts\python.exe -m PyInstaller --noconfirm --clean app\LEDMapper.spec
``` ```
Сборочный `spec` ограничивает поиск DLL каталогами Python, виртуального окружения и Windows. Это защищает EXE от несовместимых системных библиотек сторонних программ, добавленных в `PATH`. После сборки проверяйте готовый файл командой `dist\LEDMapper-vX.Y.Z.exe --verify-bundle`: успешная проверка завершается с кодом `0` без открытия окна приложения.
В чистой сборке из релизного тега файл будет называться: В чистой сборке из релизного тега файл будет называться:
```text ```text

View File

@ -16,9 +16,11 @@ from PySide6.QtWidgets import (
) )
try: try:
from .background_image_loader import supported_image_filter
from .localization import localize_dialog_buttons from .localization import localize_dialog_buttons
from .models import BackgroundImage from .models import BackgroundImage
except ImportError: except ImportError:
from background_image_loader import supported_image_filter
from localization import localize_dialog_buttons from localization import localize_dialog_buttons
from models import BackgroundImage from models import BackgroundImage
@ -34,7 +36,7 @@ class BackgroundDialog(QDialog):
self._source_background = background self._source_background = background
self._keep_embedded_image = background.has_embedded_image() 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 = 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) self.path.setReadOnly(self._keep_embedded_image)
if self._keep_embedded_image: if self._keep_embedded_image:
source_name = background.embedded_image_name or "изображение" source_name = background.embedded_image_name or "изображение"
@ -97,13 +99,13 @@ class BackgroundDialog(QDialog):
self.scale_y.setValue(100.0) self.scale_y.setValue(100.0)
def _browse(self) -> None: 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() start_path = Path(start).parent if start else Path.home()
selected, _ = QFileDialog.getOpenFileName( selected, _ = QFileDialog.getOpenFileName(
self, self,
"Выбрать подложку", "Выбрать подложку",
str(start_path), str(start_path),
"Изображения (*.png *.jpg *.jpeg);;Все файлы (*.*)", supported_image_filter(),
) )
if selected: if selected:
self._set_external_path(selected) self._set_external_path(selected)

147
background_image_loader.py Normal file
View 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()

244
canvas.py
View File

@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import math import math
from pathlib import Path
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QPointF, QRectF, QSize, Qt, QTimer, Signal from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QBrush, QImage, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform from PySide6.QtGui import QColor, QBrush, QFont, QPainter, QPainterPath, QPen, QPixmap, QTransform
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QGraphicsEllipseItem, QGraphicsEllipseItem,
@ -15,6 +14,7 @@ from PySide6.QtWidgets import (
QGraphicsRectItem, QGraphicsRectItem,
QGraphicsScene, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsSimpleTextItem,
QGraphicsTextItem,
QGraphicsView, QGraphicsView,
QMenu, QMenu,
QScrollBar, QScrollBar,
@ -22,12 +22,13 @@ from PySide6.QtWidgets import (
from shiboken6 import isValid from shiboken6 import isValid
try: try:
from .background_image_loader import background_cache_key, load_background_image
from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
except ImportError: except ImportError:
from background_image_loader import background_cache_key, load_background_image
from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
MAX_BACKGROUND_PIXMAP_SIDE = 8192
MAX_GRID_LINES = 800 MAX_GRID_LINES = 800
@ -55,6 +56,43 @@ class LedItem(QGraphicsEllipseItem):
) )
class CommentItem(QGraphicsTextItem):
"""Свободная заметка; положение хранится в координатах документа."""
def __init__(self, comment, dark: bool) -> None: # noqa: ANN001
super().__init__()
self.comment_id = comment.id
self.dark = dark
self.setPlainText(comment.text)
font = QFont()
font.setPixelSize(14)
self.setFont(font)
self.document().setDocumentMargin(10)
self.setTextWidth(240)
self.setTextInteractionFlags(Qt.NoTextInteraction)
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
self.setZValue(55)
self.setCursor(Qt.SizeAllCursor)
self.setToolTip("Перетащите комментарий мышью. Двойной щелчок — изменить текст. ПКМ — меню.")
self.apply_theme(dark)
def apply_theme(self, dark: bool) -> None:
self.dark = dark
self.setDefaultTextColor(QColor("#fef3c7" if dark else "#451a03"))
self.update()
def paint(self, painter, option, widget=None) -> None: # noqa: ANN001
painter.save()
painter.setBrush(QColor("#483b20" if self.dark else "#fff4c2"))
pen = QPen(QColor("#b68c3d" if self.dark else "#d4a437"), 1)
pen.setCosmetic(True)
painter.setPen(pen)
painter.drawRoundedRect(self.boundingRect().adjusted(0.5, 0.5, -0.5, -0.5), 5, 5)
painter.restore()
super().paint(painter, option, widget)
class LedCanvas(QGraphicsView): class LedCanvas(QGraphicsView):
selectionChanged = Signal(list) selectionChanged = Signal(list)
deleteSelectionRequested = Signal() deleteSelectionRequested = Signal()
@ -68,6 +106,10 @@ class LedCanvas(QGraphicsView):
brushPointTouched = Signal(int) brushPointTouched = Signal(int)
brushStrokeFinished = Signal() brushStrokeFinished = Signal()
addLedRequested = Signal(float, float) addLedRequested = Signal(float, float)
addCommentRequested = Signal(float, float)
editCommentRequested = Signal(str)
deleteCommentRequested = Signal(str)
commentMoveFinished = Signal(str, float, float)
backgroundEditStarted = Signal() backgroundEditStarted = Signal()
backgroundChanged = Signal() backgroundChanged = Signal()
backgroundEditFinished = Signal(bool) backgroundEditFinished = Signal(bool)
@ -85,6 +127,8 @@ class LedCanvas(QGraphicsView):
self.document: MappingDocument | None = None self.document: MappingDocument | None = None
self.active_line_id: int | None = None self.active_line_id: int | None = None
self.items_by_id: dict[int, LedItem] = {} self.items_by_id: dict[int, LedItem] = {}
self.comment_items: dict[str, CommentItem] = {}
self._comment_drag: dict | None = None
self.grid_items: list[QGraphicsLineItem] = [] self.grid_items: list[QGraphicsLineItem] = []
self.connection_items: list[QGraphicsPathItem] = [] self.connection_items: list[QGraphicsPathItem] = []
self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {} self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {}
@ -93,11 +137,13 @@ class LedCanvas(QGraphicsView):
self._background_handles: dict[str, QGraphicsRectItem] = {} self._background_handles: dict[str, QGraphicsRectItem] = {}
self._background_edit_mode = False self._background_edit_mode = False
self._background_drag: dict | None = None 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_pixmap_cache: QPixmap | None = None
self._background_load_error = ""
self._background_loaded = False self._background_loaded = False
self._direction_arrows_visible = True self._direction_arrows_visible = True
self._line_index_labels_visible = True self._line_index_labels_visible = True
self._line_index_font_size_px = 12
self._point_radius = 4.5 self._point_radius = 4.5
self._brush_radius = 18.0 self._brush_radius = 18.0
self._zoom_focus_pull = 0.35 self._zoom_focus_pull = 0.35
@ -238,6 +284,18 @@ class LedCanvas(QGraphicsView):
def line_index_labels_visible(self) -> bool: def line_index_labels_visible(self) -> bool:
return self._line_index_labels_visible 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: def set_brush_radius(self, radius: int) -> None:
self._brush_radius = float(max(4, min(80, radius))) self._brush_radius = float(max(4, min(80, radius)))
if self._brush_zone_item is not None and isValid(self._brush_zone_item): if self._brush_zone_item is not None and isValid(self._brush_zone_item):
@ -318,6 +376,15 @@ class LedCanvas(QGraphicsView):
def background_loaded(self) -> bool: def background_loaded(self) -> bool:
return self._background_loaded 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: def set_background(self, background: BackgroundImage) -> None:
if self.document is None: if self.document is None:
return return
@ -377,6 +444,8 @@ class LedCanvas(QGraphicsView):
self.setUpdatesEnabled(False) self.setUpdatesEnabled(False)
try: try:
self.items_by_id.clear() self.items_by_id.clear()
self.comment_items.clear()
self._comment_drag = None
self.grid_items.clear() self.grid_items.clear()
self.connection_items.clear() self.connection_items.clear()
self.line_index_label_items.clear() self.line_index_label_items.clear()
@ -399,8 +468,15 @@ class LedCanvas(QGraphicsView):
self._raw_bounds = None self._raw_bounds = None
self._content_scene_rect = None self._content_scene_rect = None
if self.document is None or not self.document.points: if self.document is None or not self.document.points:
self._display_scale = 1.0
self._refresh_background() self._refresh_background()
self._refresh_comments()
self._update_scene_rect() self._update_scene_rect()
if reset_view:
self.fit_to_content()
else:
self.setTransform(old_transform)
self.centerOn(old_center)
return return
# Keep display scale completely stable to prevent grid and camera jumps # Keep display scale completely stable to prevent grid and camera jumps
@ -431,6 +507,7 @@ class LedCanvas(QGraphicsView):
stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds
self._update_led_content_rect() self._update_led_content_rect()
self._refresh_background() self._refresh_background()
self._refresh_comments()
self._update_scene_rect() self._update_scene_rect()
self._refresh_grid() self._refresh_grid()
self.refresh_styles() self.refresh_styles()
@ -453,6 +530,8 @@ class LedCanvas(QGraphicsView):
self.centerOn(old_center) self.centerOn(old_center)
def refresh_styles(self, redraw_connections: bool = True) -> None: def refresh_styles(self, redraw_connections: bool = True) -> None:
for comment_item in self.comment_items.values():
comment_item.apply_theme(self._theme == "dark")
if self.document is None: if self.document is None:
return return
if redraw_connections: if redraw_connections:
@ -553,32 +632,13 @@ class LedCanvas(QGraphicsView):
self._background_loaded = False self._background_loaded = False
def _load_background_pixmap(self, background: BackgroundImage) -> QPixmap: def _load_background_pixmap(self, background: BackgroundImage) -> QPixmap:
buffer: QBuffer | None = None document_path = self.document.path if self.document is not None else None
if background.has_embedded_image(): cache_key = background_cache_key(background, document_path)
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))
if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None: 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 return self._background_pixmap_cache
size = reader.size() result = load_background_image(background, document_path)
if size.isValid() and max(size.width(), size.height()) > MAX_BACKGROUND_PIXMAP_SIDE: self._background_load_error = result.error
factor = MAX_BACKGROUND_PIXMAP_SIDE / max(size.width(), size.height()) pixmap = QPixmap.fromImage(result.image) if result.loaded else QPixmap()
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()
self._background_pixmap_cache_key = cache_key self._background_pixmap_cache_key = cache_key
self._background_pixmap_cache = pixmap self._background_pixmap_cache = pixmap
return pixmap return pixmap
@ -620,8 +680,47 @@ class LedCanvas(QGraphicsView):
return None return None
return self._background_item.sceneBoundingRect() return self._background_item.sceneBoundingRect()
def _refresh_comments(self) -> None:
for item in self.comment_items.values():
if isValid(item):
self.scene().removeItem(item)
self.comment_items.clear()
if self.document is None:
return
for comment in self.document.comments:
item = CommentItem(comment, self._theme == "dark")
item.setPos(self._to_scene_point(comment.x, comment.y))
self.scene().addItem(item)
self.comment_items[comment.id] = item
def _comment_at(self, viewport_pos) -> CommentItem | None: # noqa: ANN001
for item in self.items(viewport_pos):
if isinstance(item, CommentItem):
return item
return None
def _rect_with_comments(self, rect: QRectF | None) -> QRectF | None:
scale = max(abs(self.transform().m11()), 1e-9)
for item in self.comment_items.values():
local = item.boundingRect().adjusted(-20, -20, 20, 20)
bounds = QRectF(
item.pos().x() + local.x() / scale, item.pos().y() + local.y() / scale,
local.width() / scale, local.height() / scale,
)
rect = bounds if rect is None else rect.united(bounds)
return rect
def mouseDoubleClickEvent(self, event) -> None: # noqa: ANN001
item = self._comment_at(event.pos())
if event.button() == Qt.LeftButton and item is not None:
self._comment_drag = None
self.editCommentRequested.emit(item.comment_id)
event.accept()
return
super().mouseDoubleClickEvent(event)
def _update_scene_rect(self) -> None: def _update_scene_rect(self) -> None:
rect = self._content_scene_rect rect = self._rect_with_comments(self._content_scene_rect)
background_rect = self._background_scene_rect() background_rect = self._background_scene_rect()
if rect is None: if rect is None:
rect = background_rect rect = background_rect
@ -675,21 +774,26 @@ class LedCanvas(QGraphicsView):
handle.setPos(point) handle.setPos(point)
def fit_to_content(self) -> None: def fit_to_content(self) -> None:
self._update_scene_rect() if self.scene() is None:
if self.scene() is None or self.sceneRect().isEmpty():
return return
# Размер заметок в пикселях постоянен; их границы в сцене зависят от масштаба.
# Focus camera on actual document content bounds, ignoring expanded edit sceneRect bounds for _ in range(8 if self.comment_items else 1):
rect = self._content_scene_rect self._update_scene_rect()
background_rect = self._background_scene_rect() rect = self._rect_with_comments(self._content_scene_rect)
if rect is None: background_rect = self._background_scene_rect()
rect = background_rect if rect is None:
elif background_rect is not None: rect = background_rect
rect = rect.united(background_rect) elif background_rect is not None:
if rect is None or rect.isEmpty(): rect = rect.united(background_rect)
rect = self.sceneRect() if rect is None or rect.isEmpty():
rect = self.sceneRect()
self.fitInView(rect, Qt.KeepAspectRatio) if rect.isEmpty():
return
self.fitInView(rect, Qt.KeepAspectRatio)
if self.comment_items and self.transform().m11() > 1.0:
self.resetTransform()
self.centerOn(rect.center())
break
self._refresh_grid() self._refresh_grid()
self._position_line_index_labels() self._position_line_index_labels()
self._update_selection_bbox() self._update_selection_bbox()
@ -795,6 +899,16 @@ class LedCanvas(QGraphicsView):
self._start_pan(event.pos()) self._start_pan(event.pos())
event.accept() event.accept()
return return
if event.button() == Qt.LeftButton:
comment = self._comment_at(event.pos())
if comment is not None:
self.setFocus(Qt.MouseFocusReason)
self._comment_drag = {
"item": comment, "start": self.mapToScene(event.pos()),
"origin": QPointF(comment.pos()),
}
event.accept()
return
is_move_tool = getattr(self, "_edit_tool", "select") == "move" is_move_tool = getattr(self, "_edit_tool", "select") == "move"
has_alt = bool(event.modifiers() & Qt.AltModifier) has_alt = bool(event.modifiers() & Qt.AltModifier)
if event.button() == Qt.LeftButton and (has_alt or is_move_tool) and self._start_point_drag(event.pos()): if event.button() == Qt.LeftButton and (has_alt or is_move_tool) and self._start_point_drag(event.pos()):
@ -851,6 +965,11 @@ class LedCanvas(QGraphicsView):
self._move_scrollbar(self.verticalScrollBar(), delta.y()) self._move_scrollbar(self.verticalScrollBar(), delta.y())
event.accept() event.accept()
return return
if self._comment_drag is not None:
drag = self._comment_drag
drag["item"].setPos(drag["origin"] + self.mapToScene(event.pos()) - drag["start"])
event.accept()
return
if self._background_drag is not None: if self._background_drag is not None:
self._move_background_edit(event.pos()) self._move_background_edit(event.pos())
event.accept() event.accept()
@ -901,6 +1020,17 @@ class LedCanvas(QGraphicsView):
self._finish_pan() self._finish_pan()
event.accept() event.accept()
return return
if event.button() == Qt.LeftButton and self._comment_drag is not None:
drag = self._comment_drag
self._comment_drag = None
position = drag["origin"] + self.mapToScene(event.pos()) - drag["start"]
item = drag["item"]
item.setPos(position)
if position != drag["origin"]:
point = self._document_point_from_scene(position)
self.commentMoveFinished.emit(item.comment_id, point.x(), point.y())
event.accept()
return
if event.button() == Qt.LeftButton and self._background_drag is not None: if event.button() == Qt.LeftButton and self._background_drag is not None:
self._finish_background_edit() self._finish_background_edit()
event.accept() event.accept()
@ -1273,6 +1403,9 @@ class LedCanvas(QGraphicsView):
) )
def _start_pan(self, pos) -> None: # noqa: ANN001 def _start_pan(self, pos) -> None: # noqa: ANN001
if self._comment_drag is not None:
self._comment_drag["item"].setPos(self._comment_drag["origin"])
self._comment_drag = None
self._is_panning = True self._is_panning = True
self._last_pan_pos = pos self._last_pan_pos = pos
self._apply_default_cursor() self._apply_default_cursor()
@ -1283,10 +1416,24 @@ class LedCanvas(QGraphicsView):
self._apply_default_cursor() self._apply_default_cursor()
def contextMenuEvent(self, event) -> None: # noqa: ANN001 def contextMenuEvent(self, event) -> None: # noqa: ANN001
comment = self._comment_at(event.pos())
if comment is not None:
menu = QMenu(self)
edit_comment = menu.addAction("Изменить комментарий…")
delete_comment = menu.addAction("Удалить комментарий")
chosen = menu.exec(event.globalPos())
if chosen == edit_comment:
self.editCommentRequested.emit(comment.comment_id)
elif chosen == delete_comment:
self.deleteCommentRequested.emit(comment.comment_id)
return
item = self._led_item_at(event.pos()) item = self._led_item_at(event.pos())
point_id = item.point_id if item is not None else self._single_selected_point_id() point_id = item.point_id if item is not None else self._single_selected_point_id()
menu = QMenu(self) menu = QMenu(self)
add_comment = menu.addAction("Добавить комментарий…")
comment_position = self._document_point_from_scene(self.mapToScene(event.pos()))
menu.addSeparator()
edit_action = menu.addAction("Редактировать координаты") edit_action = menu.addAction("Редактировать координаты")
edit_action.setEnabled(point_id is not None) edit_action.setEnabled(point_id is not None)
delete_action = menu.addAction("Удалить фиксацию выбранных LED") delete_action = menu.addAction("Удалить фиксацию выбранных LED")
@ -1334,9 +1481,11 @@ class LedCanvas(QGraphicsView):
select_block_action = menu.addAction(f"Выделить блок '{block_name}'") select_block_action = menu.addAction(f"Выделить блок '{block_name}'")
fit_action = menu.addAction("Показать все точки") fit_action = menu.addAction("Показать все точки")
fit_action.setEnabled(self.document is not None and bool(self.document.points)) fit_action.setEnabled(self.document is not None and bool(self.document.points or self.document.comments))
chosen = menu.exec(event.globalPos()) chosen = menu.exec(event.globalPos())
if chosen == edit_action and point_id is not None: if chosen == add_comment:
self.addCommentRequested.emit(comment_position.x(), comment_position.y())
elif chosen == edit_action and point_id is not None:
self.editPointRequested.emit(point_id) self.editPointRequested.emit(point_id)
elif chosen == delete_action: elif chosen == delete_action:
self.deleteSelectionRequested.emit() self.deleteSelectionRequested.emit()
@ -1404,6 +1553,9 @@ class LedCanvas(QGraphicsView):
if item is None or not isValid(item): if item is None or not isValid(item):
continue continue
label = QGraphicsSimpleTextItem(str(point.line_index)) 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.setBrush(QColor("#f8fafc" if self._theme == "dark" else "#111827"))
label.setZValue(40) label.setZValue(40)
label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)

View File

@ -0,0 +1,13 @@
# Свободные текстовые комментарии — план
Цель: свободные многострочные заметки на схеме, согласованные пользователем 05.11.2026.
Архитектура: отдельная коллекция `comments` документа с устойчивым ID, текстом и координатами в единицах документа. Холст отвечает за отображение и жесты, главное окно — за диалог и историю. Существующие файлы без comments загружаются с пустой коллекцией; неизвестные поля сохраняются. Физический маппинг не изменяется.
Варианты: привязка к LED/линиям отклонена пользователем; выбрано свободное расположение. Техническое решение: встроить объекты в существующий QGraphicsView и deepcopy-историю без новых зависимостей.
- [x] Модель: тест round-trip Unicode, отрицательных/дробных координат, неизвестных полей и старого JSON; добавить TextComment и CRUD.
- [x] Интерфейс: контекстное меню добавления/изменения/удаления, двойной щелчок, перетаскивание. Проверить Undo/Redo, отмену/пустой текст, пустую схему, масштаб и единицы m/mm.
- [x] Документация и проверка: README, полный smoke-набор, py_compile, интерактивные события Qt и рендер холста. Сохранить фактические результаты в AgentMemory.
Ограничения: использовать .venv; сохранить существующие пользовательские изменения; не создавать коммиты и релизы. Обнаружена грязная рабочая копия с HEAD 3699586 — это отличается от исторической записи памяти о релизе v3.4.0; version.py содержит 3.4.0. Работа ведётся поверх текущих файлов без отката.

View File

@ -26,6 +26,9 @@ except ImportError:
def main() -> int: def main() -> int:
app = QApplication(sys.argv) app = QApplication(sys.argv)
if "--verify-bundle" in sys.argv:
app.processEvents()
return 0
settings = AppSettings.load() settings = AppSettings.load()
apply_app_theme(settings.theme) apply_app_theme(settings.theme)
icon_color = "#111827" if effective_theme(settings.theme) == "light" else "#e5e7eb" icon_color = "#111827" if effective_theme(settings.theme) == "light" else "#e5e7eb"

View File

@ -5,7 +5,7 @@ import sys
from threading import Event, Thread from threading import Event, Thread
from PySide6.QtCore import QEvent, QObject, QSignalBlocker, QSize, Qt, QTimer, Signal 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 ( from PySide6.QtWidgets import (
QButtonGroup, QButtonGroup,
QAbstractItemView, QAbstractItemView,
@ -269,6 +269,10 @@ class MainWindow(QMainWindow):
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point) self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise) self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise)
self.canvas.addLedRequested.connect(self.add_manual_led) self.canvas.addLedRequested.connect(self.add_manual_led)
self.canvas.addCommentRequested.connect(self.add_comment_from_canvas)
self.canvas.editCommentRequested.connect(self.edit_comment)
self.canvas.deleteCommentRequested.connect(self.delete_comment)
self.canvas.commentMoveFinished.connect(self.move_comment_from_canvas)
self.canvas.brushStrokeStarted.connect(self.start_brush_stroke) self.canvas.brushStrokeStarted.connect(self.start_brush_stroke)
self.canvas.brushPointTouched.connect(self.add_brush_point_to_block) self.canvas.brushPointTouched.connect(self.add_brush_point_to_block)
self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke) self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke)
@ -374,6 +378,7 @@ class MainWindow(QMainWindow):
self.actions[key] = action self.actions[key] = action
return action return action
add_action("add_comment", "Добавить комментарий…", self.add_comment_at_center)
add_action("new", "Создать...", self.new_file, "file-plus", QKeySequence.New) add_action("new", "Создать...", self.new_file, "file-plus", QKeySequence.New)
add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open) add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open)
add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save) add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save)
@ -553,6 +558,7 @@ class MainWindow(QMainWindow):
tools_menu = menu_bar.addMenu("Инструменты") tools_menu = menu_bar.addMenu("Инструменты")
tools_menu.addAction(self.actions["add_line"]) tools_menu.addAction(self.actions["add_line"])
tools_menu.addAction(self.actions["add_led"]) tools_menu.addAction(self.actions["add_led"])
tools_menu.addAction(self.actions["add_comment"])
tools_menu.addAction(self.actions["generate_script"]) tools_menu.addAction(self.actions["generate_script"])
tools_menu.addSeparator() tools_menu.addSeparator()
tools_menu.addAction(self.actions["auto_blocks"]) tools_menu.addAction(self.actions["auto_blocks"])
@ -603,15 +609,23 @@ class MainWindow(QMainWindow):
if not hasattr(self, "menu_buttons_widget") or self.menu_buttons_widget is None: if not hasattr(self, "menu_buttons_widget") or self.menu_buttons_widget is None:
return return
menu_bar = self.menuBar() menu_bar = self.menuBar()
actions = menu_bar.actions() action_rects = [
if len(actions) < 4: 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 return
tools_action = actions[3] left = max(rect.right() for rect in action_rects) + 10
rect = menu_bar.actionGeometry(tools_action) height = menu_bar.height()
if rect.isValid() and rect.right() > 0: width = self.menu_buttons_widget.sizeHint().width()
h = menu_bar.height() if left + width > menu_bar.contentsRect().right() + 1:
w = self.menu_buttons_widget.sizeHint().width() self.menu_buttons_widget.hide()
self.menu_buttons_widget.setGeometry(rect.right() + 10, (h - 20) // 2, w, 20) return
self.menu_buttons_widget.setGeometry(left, max(0, (height - 20) // 2), width, 20)
self.menu_buttons_widget.show()
def _build_layout(self) -> None: def _build_layout(self) -> None:
side = QWidget() side = QWidget()
@ -1026,14 +1040,10 @@ class MainWindow(QMainWindow):
background = dialog.background() background = dialog.background()
if not background.is_enabled(): if not background.is_enabled():
return return
if not background.has_embedded_image(): if not self.canvas.validate_background(background):
image_path = Path(background.path) details = self.canvas.background_load_error() or "Формат или содержимое изображения не поддерживается."
if not image_path.is_absolute() and self.document.path is not None: show_warning(self, "Подложка не загружена", details)
image_path = self.document.path.parent / image_path return
reader = QImageReader(str(image_path))
if not reader.canRead():
show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.")
return
if background.to_payload() == self.document.background.to_payload(): if background.to_payload() == self.document.background.to_payload():
self.status.setText("Подложка не изменилась.") self.status.setText("Подложка не изменилась.")
return return
@ -1118,12 +1128,15 @@ class MainWindow(QMainWindow):
dialog_settings = deepcopy(self.settings) dialog_settings = deepcopy(self.settings)
dialog_settings.grid_step_mm = self.document.grid_step_mm dialog_settings.grid_step_mm = self.document.grid_step_mm
dialog = SettingsDialog(dialog_settings, self) 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() self._disable_shortcuts()
try: try:
res = dialog.exec() res = dialog.exec()
finally: finally:
self._enable_shortcuts() self._enable_shortcuts()
if res != SettingsDialog.Accepted: if res != SettingsDialog.Accepted:
self.canvas.set_line_index_font_size(previous_index_font_size)
return return
new_settings = dialog.settings() new_settings = dialog.settings()
grid_changed = abs(self.document.grid_step_mm - new_settings.grid_step_mm) > 1e-9 grid_changed = abs(self.document.grid_step_mm - new_settings.grid_step_mm) > 1e-9
@ -1168,6 +1181,7 @@ class MainWindow(QMainWindow):
self.canvas.set_grid_visible(self.settings.show_grid_by_default) 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_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_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_brush_radius(self.settings.brush_radius)
self.canvas.set_grid_step_mm(self.document.grid_step_mm) self.canvas.set_grid_step_mm(self.document.grid_step_mm)
self.canvas.set_point_diameter(self.settings.led_point_diameter_px) self.canvas.set_point_diameter(self.settings.led_point_diameter_px)
@ -1710,6 +1724,55 @@ class MainWindow(QMainWindow):
self.status.setText(f"Создана {line.name}. {self._line_summary(line)}") self.status.setText(f"Создана {line.name}. {self._line_summary(line)}")
self._refresh_ui_state() self._refresh_ui_state()
def add_comment_at_center(self) -> None:
point = self.canvas._document_point_from_scene(
self.canvas.mapToScene(self.canvas.viewport().rect().center())
)
self.add_comment_from_canvas(point.x(), point.y())
def add_comment_from_canvas(self, x: float, y: float) -> None:
text, accepted = QInputDialog.getMultiLineText(self, "Новый комментарий", "Текст комментария:")
if not accepted or not text.strip():
return
self._push_undo()
self.document.add_comment(text, x, y)
self._after_comment_change("Комментарий добавлен.")
def edit_comment(self, comment_id: str) -> None:
comment = self.document.get_comment(comment_id)
text, accepted = QInputDialog.getMultiLineText(
self, "Изменить комментарий", "Текст комментария:", comment.text,
)
if not accepted or not text.strip() or text == comment.text:
return
self._push_undo()
self.document.update_comment(comment_id, text=text)
self._after_comment_change("Комментарий изменён.")
def move_comment_from_canvas(self, comment_id: str, x: float, y: float) -> None:
comment = self.document.get_comment(comment_id)
if (comment.x, comment.y) == (x, y):
return
self._push_undo()
self.document.update_comment(comment_id, x=x, y=y)
self._after_comment_change("Комментарий перемещён.")
def delete_comment(self, comment_id: str) -> None:
self.document.get_comment(comment_id)
self._push_undo()
self.document.remove_comment(comment_id)
self._after_comment_change("Комментарий удалён.")
def _after_comment_change(self, message: str) -> None:
self._dirty = True
if self.canvas.document is not self.document:
self.canvas.set_document(self.document, reset_view=False)
else:
self.canvas._refresh_comments()
self.canvas._update_scene_rect()
self.status.setText(message)
self._refresh_ui_state()
def add_manual_led(self, x: float, y: float) -> None: def add_manual_led(self, x: float, y: float) -> None:
self._push_undo() self._push_undo()
point = self.document.add_manual_point(x, y) point = self.document.add_manual_point(x, y)
@ -2830,8 +2893,8 @@ class MainWindow(QMainWindow):
self.selection_label.setText(self._selection_summary(point_ids)) self.selection_label.setText(self._selection_summary(point_ids))
state = { state = {
"save": has_points and self._dirty, "save": bool(has_points or self.document.comments or self.document.path is not None) and self._dirty,
"save_as": has_points, "save_as": bool(has_points or self.document.comments or self.document.path is not None),
"export_image": has_points, "export_image": has_points,
"generate_script": has_points, "generate_script": has_points,
"undo": bool(self._undo_stack), "undo": bool(self._undo_stack),
@ -2850,7 +2913,7 @@ class MainWindow(QMainWindow):
"delete_points": has_selection, "delete_points": has_selection,
"clear_all": has_lines, "clear_all": has_lines,
"clear_selection": has_selection, "clear_selection": has_selection,
"fit_view": has_points or has_visible_background, "fit_view": has_points or has_visible_background or bool(self.document.comments),
"grid": True, "grid": True,
"background": True, "background": True,
"background_visible": has_loaded_background, "background_visible": has_loaded_background,

View File

@ -9,6 +9,7 @@ import mimetypes
import os import os
import re import re
import tempfile import tempfile
import uuid
from collections import deque from collections import deque
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
@ -26,6 +27,7 @@ MAX_NEIGHBOR_TOLERANCE_MM = 2.0
METER_LIKE_MAX_SPAN = 10.0 METER_LIKE_MAX_SPAN = 10.0
_DOCUMENT_KNOWN_FIELDS = { _DOCUMENT_KNOWN_FIELDS = {
"background", "background",
"comments",
"circles", "circles",
"coordinate_unit", "coordinate_unit",
"grid_step_mm", "grid_step_mm",
@ -311,12 +313,45 @@ class BackgroundImage:
) )
@dataclass
class TextComment:
id: str
text: str
x: float
y: float
extra_payload: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TextComment":
text, x, y = cls.validate(payload.get("text", ""), payload.get("x", 0), payload.get("y", 0))
return cls(
id=str(payload.get("id") or uuid.uuid4().hex), text=text, x=x, y=y,
extra_payload={key: deepcopy(value) for key, value in payload.items()
if key not in {"id", "text", "x", "y"}},
)
@staticmethod
def validate(text: str, x: float, y: float) -> tuple[str, float, float]:
if not isinstance(text, str) or not text.strip():
raise ValueError("Введите текст комментария.")
x, y = float(x), float(y)
if not math.isfinite(x) or not math.isfinite(y):
raise ValueError("Координаты комментария должны быть конечными числами.")
return text, x, y
def to_payload(self) -> dict[str, Any]:
text, x, y = self.validate(self.text, self.x, self.y)
return {**deepcopy(self.extra_payload), "id": self.id, "text": text, "x": x, "y": y}
class MappingDocument: class MappingDocument:
def __init__(self) -> None: def __init__(self) -> None:
self.path: Path | None = None self.path: Path | None = None
self.collection_name = "circles" self.collection_name = "circles"
self.points: list[LedPoint] = [] self.points: list[LedPoint] = []
self.lines: list[LedLine] = [] self.lines: list[LedLine] = []
self.comments: list[TextComment] = []
self.background = BackgroundImage() self.background = BackgroundImage()
self.unfolding = UnfoldingDescription() self.unfolding = UnfoldingDescription()
self.coordinate_unit = "mm" self.coordinate_unit = "mm"
@ -330,6 +365,7 @@ class MappingDocument:
self.collection_name = "circles" self.collection_name = "circles"
self.points.clear() self.points.clear()
self.lines.clear() self.lines.clear()
self.comments.clear()
self.background = BackgroundImage() self.background = BackgroundImage()
self.unfolding = UnfoldingDescription() self.unfolding = UnfoldingDescription()
self.coordinate_unit = "mm" self.coordinate_unit = "mm"
@ -385,6 +421,18 @@ class MappingDocument:
) )
self._canonicalize_coordinate_unit() self._canonicalize_coordinate_unit()
comments_data = data.get("comments", [])
if not isinstance(comments_data, list):
raise ValueError("Поле comments должно содержать список комментариев.")
seen_comment_ids: set[str] = set()
for payload in comments_data:
if not isinstance(payload, dict):
raise ValueError("Комментарий должен быть объектом JSON.")
comment = TextComment.from_payload(payload)
if comment.id in seen_comment_ids:
comment.id = uuid.uuid4().hex
seen_comment_ids.add(comment.id)
self.comments.append(comment)
self._load_lines(data) self._load_lines(data)
self._migrate_legacy_index_base(data) self._migrate_legacy_index_base(data)
@ -553,6 +601,7 @@ class MappingDocument:
payload["index_base"] = 0 payload["index_base"] = 0
payload["background"] = (self.background if background is None else background).to_payload() payload["background"] = (self.background if background is None else background).to_payload()
payload["lines"] = self.lines_to_payload() if lines_payload is None else lines_payload payload["lines"] = self.lines_to_payload() if lines_payload is None else lines_payload
payload["comments"] = [comment.to_payload() for comment in self.comments]
return payload return payload
def point_to_payload(self, point: LedPoint, *, id_value: int | None = None) -> dict[str, Any]: def point_to_payload(self, point: LedPoint, *, id_value: int | None = None) -> dict[str, Any]:
@ -596,6 +645,32 @@ class MappingDocument:
for line in self.lines for line in self.lines
] ]
def add_comment(self, text: str, x: float, y: float) -> TextComment:
text, x, y = TextComment.validate(text, x, y)
comment = TextComment(uuid.uuid4().hex, text, x, y)
self.comments.append(comment)
return comment
def get_comment(self, comment_id: str) -> TextComment:
for comment in self.comments:
if comment.id == comment_id:
return comment
raise KeyError(comment_id)
def update_comment(
self, comment_id: str, *, text: str | None = None,
x: float | None = None, y: float | None = None,
) -> None:
comment = self.get_comment(comment_id)
values = TextComment.validate(
comment.text if text is None else text,
comment.x if x is None else x, comment.y if y is None else y,
)
comment.text, comment.x, comment.y = values
def remove_comment(self, comment_id: str) -> None:
self.comments.remove(self.get_comment(comment_id))
def add_line(self) -> LedLine: def add_line(self) -> LedLine:
line_limit = self._line_limit() line_limit = self._line_limit()
if len(self.lines) >= line_limit: if len(self.lines) >= line_limit:

View File

@ -31,7 +31,9 @@ from app.models import (
order_points_like_grid_svg, order_points_like_grid_svg,
) )
from app.panel_mapping_generator import generate_panel_mapping_from_document 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 from app.update_service import UpdateError, check_for_update, download_update
import app.settings as settings_module
import app.update_service as updater import app.update_service as updater
from app.unfolding import MAX_LINES from app.unfolding import MAX_LINES
try: try:
@ -44,8 +46,8 @@ try:
from app.settings import DEFAULT_SHORTCUTS, AppSettings from app.settings import DEFAULT_SHORTCUTS, AppSettings
from app.settings_dialog import SettingsDialog from app.settings_dialog import SettingsDialog
from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet
from PySide6.QtCore import QPoint, QPointF, QRectF, Qt from PySide6.QtCore import QPoint, QPointF, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QImage, QKeySequence from PySide6.QtGui import QColor, QImage, QImageIOHandler, QImageWriter, QKeySequence
from PySide6.QtTest import QTest from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QLineEdit, QMessageBox, QToolBar from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QLineEdit, QMessageBox, QToolBar
except ImportError: except ImportError:
@ -71,9 +73,12 @@ except ImportError:
QPoint = None QPoint = None
QPointF = None QPointF = None
QRectF = None QRectF = None
QSize = None
Qt = None Qt = None
QColor = None QColor = None
QImage = None QImage = None
QImageIOHandler = None
QImageWriter = None
QKeySequence = None QKeySequence = None
QApplication = None QApplication = None
QDialogButtonBox = None QDialogButtonBox = None
@ -96,6 +101,13 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
def main() -> None: def main() -> None:
tests: list[Callable[[], None]] = [ tests: list[Callable[[], None]] = [
test_text_comments_roundtrip_and_legacy,
test_text_comments_validate_and_keep_identity,
test_text_comments_gui_history_and_cancel,
test_text_comments_canvas_drag_and_units,
test_text_comments_fit_and_context_menu,
test_text_comments_only_fit_stays_stable,
test_text_comments_do_not_change_physical_mapping,
test_load_empty_json_file, test_load_empty_json_file,
test_load_supported_json_files, test_load_supported_json_files,
test_load_raw_json_with_trailing_commas_and_bare_decimal_points, test_load_raw_json_with_trailing_commas_and_bare_decimal_points,
@ -145,6 +157,7 @@ def main() -> None:
test_uid_point_id_mode_does_not_fallback_to_source_ids, test_uid_point_id_mode_does_not_fallback_to_source_ids,
test_panel_mapping_generation_from_document, test_panel_mapping_generation_from_document,
test_update_release_validation_and_download, test_update_release_validation_and_download,
test_update_download_redirects_permissions_and_cancellation,
test_update_install_keeps_executable_path_and_bundled_version, test_update_install_keeps_executable_path_and_bundled_version,
test_ordered_json_meter_coordinates_reload_as_mm, test_ordered_json_meter_coordinates_reload_as_mm,
test_legacy_cleanup_waits_for_success, test_legacy_cleanup_waits_for_success,
@ -164,6 +177,9 @@ def main() -> None:
test_background_embedding_failure_keeps_document_and_target_unchanged, test_background_embedding_failure_keeps_document_and_target_unchanged,
test_background_dialog_preserves_non_uniform_scale, test_background_dialog_preserves_non_uniform_scale,
test_background_dialog_marks_and_preserves_embedded_image, 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_layer_and_geometry,
test_canvas_background_zero_offset_uses_scheme_center, test_canvas_background_zero_offset_uses_scheme_center,
test_canvas_background_edit_move_and_resize_helpers, test_canvas_background_edit_move_and_resize_helpers,
@ -175,6 +191,7 @@ def main() -> None:
test_canvas_background_changes_preserve_camera, test_canvas_background_changes_preserve_camera,
test_canvas_uses_grid_svg_coordinates, test_canvas_uses_grid_svg_coordinates,
test_canvas_shows_selected_line_index_labels, test_canvas_shows_selected_line_index_labels,
test_led_index_font_size_keeps_absolute_screen_size,
test_canvas_rebuild_can_preserve_user_zoom, test_canvas_rebuild_can_preserve_user_zoom,
test_grid_toggle_adds_grid_items, test_grid_toggle_adds_grid_items,
test_edit_mode_keeps_regular_grid_spacing, test_edit_mode_keeps_regular_grid_spacing,
@ -202,6 +219,7 @@ def main() -> None:
test_main_window_direction_arrow_action_toggles_canvas, test_main_window_direction_arrow_action_toggles_canvas,
test_settings_apply_to_canvas_and_generation, test_settings_apply_to_canvas_and_generation,
test_settings_dialog_has_requested_categories, test_settings_dialog_has_requested_categories,
test_app_settings_persist_led_index_font_size,
test_theme_resolution_and_canvas_apply, test_theme_resolution_and_canvas_apply,
test_menu_disabled_items_are_visually_distinct, test_menu_disabled_items_are_visually_distinct,
test_shortcut_settings_are_editable_and_layout_independent, test_shortcut_settings_are_editable_and_layout_independent,
@ -264,6 +282,7 @@ def main() -> None:
test_too_close_neighbor_has_lowest_priority, test_too_close_neighbor_has_lowest_priority,
test_meter_like_coordinates_use_34_mm_threshold, test_meter_like_coordinates_use_34_mm_threshold,
test_pyinstaller_spec_build_ref_rules, test_pyinstaller_spec_build_ref_rules,
test_pyinstaller_spec_isolates_library_paths,
] ]
for test in tests: for test in tests:
gc.collect() gc.collect()
@ -1290,9 +1309,12 @@ def test_panel_mapping_generation_from_document() -> None:
def test_update_release_validation_and_download() -> None: def test_update_release_validation_and_download() -> None:
class FakeResponse(io.BytesIO): 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) super().__init__(data)
self.url = url self.url = url
self.headers = {}
if content_length is not None:
self.headers["Content-Length"] = str(content_length)
def geturl(self) -> str: def geturl(self) -> str:
return self.url return self.url
@ -1336,6 +1358,76 @@ def test_update_release_validation_and_download() -> None:
raise AssertionError("An update on another host must be rejected") 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: def test_update_install_keeps_executable_path_and_bundled_version() -> None:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
directory = Path(temp_dir) directory = Path(temp_dir)
@ -1361,6 +1453,13 @@ def test_update_install_keeps_executable_path_and_bundled_version() -> None:
updater.launch_update(staged, info, process_id=123) updater.launch_update(staged, info, process_id=123)
arguments = popen.call_args.args[0] arguments = popen.call_args.args[0]
assert arguments[8] == arguments[9] == str(old_executable) 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]) helper = Path(arguments[5])
script = helper.read_text(encoding="utf-8-sig") script = helper.read_text(encoding="utf-8-sig")
assert "Wait-Process" in script and "$moveDeadline" in script assert "Wait-Process" in script and "$moveDeadline" in script
@ -1736,6 +1835,8 @@ def test_menu_bar_tools_buttons() -> None:
return return
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
window = MainWindow() window = MainWindow()
window.resize(900, 420)
window.show()
app.processEvents() app.processEvents()
from PySide6.QtWidgets import QToolButton, QFrame from PySide6.QtWidgets import QToolButton, QFrame
@ -1759,6 +1860,22 @@ def test_menu_bar_tools_buttons() -> None:
for key in expected_keys: for key in expected_keys:
assert window.actions[key] in button_actions 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() window.close()
@ -2021,6 +2138,63 @@ def test_background_dialog_marks_and_preserves_embedded_image() -> None:
dialog.close() 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: def test_canvas_background_layer_and_geometry() -> None:
if LedCanvas is None or QApplication is None or QImage is None: if LedCanvas is None or QApplication is None or QImage is None:
return return
@ -2149,6 +2323,12 @@ def test_canvas_background_large_image_downsamples_and_grid_degrades() -> None:
assert len(canvas.grid_items) <= 802 assert len(canvas.grid_items) <= 802
canvas.close() 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: def test_main_window_background_reset_noops_at_100() -> None:
if MainWindow is None or QApplication is None or QImage is None: if MainWindow is None or QApplication is None or QImage is None:
@ -2362,6 +2542,32 @@ def test_canvas_shows_selected_line_index_labels() -> None:
canvas.close() 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: def test_canvas_rebuild_can_preserve_user_zoom() -> None:
if LedCanvas is None or QApplication is None: if LedCanvas is None or QApplication is None:
return return
@ -2860,7 +3066,7 @@ def test_classic_menu_contains_expected_actions() -> None:
"Подложка", "Подложка",
"Скрыть боковую панель", "Скрыть боковую панель",
], ],
"Инструменты": ["Новая линия", "Новый светодиод", "Сгенерировать скрипт", "Автоблоки", "Против часовой", "Кисть"], "Инструменты": ["Новая линия", "Новый светодиод", "Добавить комментарий…", "Сгенерировать скрипт", "Автоблоки", "Против часовой", "Кисть"],
} }
for top_action in list(window.menuBar().actions()): for top_action in list(window.menuBar().actions()):
menu = top_action.menu() menu = top_action.menu()
@ -3064,6 +3270,7 @@ def test_settings_apply_to_canvas_and_generation() -> None:
window.settings.pan_mode = "space" window.settings.pan_mode = "space"
window.settings.instant_space_pan = True window.settings.instant_space_pan = True
window.settings.led_point_diameter_px = 17 window.settings.led_point_diameter_px = 17
window.settings.led_index_font_size_px = 21
window.document.grid_step_mm = 47.5 window.document.grid_step_mm = 47.5
window.settings.default_output_dir = temp_dir window.settings.default_output_dir = temp_dir
window.settings.confirm_partial_generation = False window.settings.confirm_partial_generation = False
@ -3080,6 +3287,7 @@ def test_settings_apply_to_canvas_and_generation() -> None:
assert window.canvas._brush_radius == 31 assert window.canvas._brush_radius == 31
assert window.canvas._grid_step == 47.5 assert window.canvas._grid_step == 47.5
assert abs(window.canvas._point_radius - 8.5) < 1e-9 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 abs(window.canvas._zoom_focus_pull - 0.12) < 0.001
assert window.canvas._pan_mode == "space" assert window.canvas._pan_mode == "space"
assert window.canvas._instant_space_pan is True assert window.canvas._instant_space_pan is True
@ -3104,16 +3312,32 @@ def test_settings_dialog_has_requested_categories() -> None:
assert dialog.instant_space_pan.isChecked() is False assert dialog.instant_space_pan.isChecked() is False
dialog.grid_step.setValue(50.0) dialog.grid_step.setValue(50.0)
dialog.led_point_diameter.setValue(17) dialog.led_point_diameter.setValue(17)
dialog.led_index_font_size.setValue(21)
updated = dialog.settings() updated = dialog.settings()
assert updated.grid_step_mm == 50.0 assert updated.grid_step_mm == 50.0
assert updated.led_point_diameter_px == 17 assert updated.led_point_diameter_px == 17
assert updated.led_index_font_size_px == 21
dialog.grid_step_reset.click() dialog.grid_step_reset.click()
dialog.led_point_diameter_reset.click() dialog.led_point_diameter_reset.click()
dialog.led_index_font_size_reset.click()
assert dialog.grid_step.value() == 34.0 assert dialog.grid_step.value() == 34.0
assert dialog.led_point_diameter.value() == 9 assert dialog.led_point_diameter.value() == 9
assert dialog.led_index_font_size.value() == 12
dialog.close() 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: def test_theme_resolution_and_canvas_apply() -> None:
if QApplication is None or MainWindow is None or effective_theme is None: if QApplication is None or MainWindow is None or effective_theme is None:
return return
@ -3701,6 +3925,9 @@ def test_canvas_context_menu_can_request_edit_and_counterclockwise_block() -> No
self.actions.append(action) self.actions.append(action)
return action return action
def addSeparator(self) -> None:
pass
def exec(self, _global_pos): # noqa: ANN001 def exec(self, _global_pos): # noqa: ANN001
return next(action for action in self.actions if action.text == self.next_choice) return next(action for action in self.actions if action.text == self.next_choice)
@ -4299,6 +4526,36 @@ def test_pyinstaller_spec_build_ref_rules() -> None:
assert re.fullmatch(r"\d{6}-\d{8}", dirty_ref) assert re.fullmatch(r"\d{6}-\d{8}", dirty_ref)
def test_pyinstaller_spec_isolates_library_paths() -> None:
spec_path = PROJECT_ROOT / "app" / "LEDMapper.spec"
spec_source = spec_path.read_text(encoding="utf-8")
helper_source = spec_source.split("app_name = f'LEDMapper-{git_build_ref()}'", 1)[0]
helpers = {"SPECPATH": str(spec_path.parent)}
exec(helper_source, helpers)
roots = helpers["_build_library_roots"]()
assert Path(sys.prefix).resolve() in roots
environment = helpers["os"].environ
original_path = environment.get("PATH", "")
try:
with tempfile.TemporaryDirectory() as temp_dir:
environment["PATH"] = temp_dir + helpers["os"].pathsep + original_path
helpers["_set_isolated_build_path"](roots)
assert temp_dir not in environment["PATH"].split(helpers["os"].pathsep)
helpers["_check_binary_sources"](
[("valid.dll", str(Path(sys.prefix) / "valid.dll"), "BINARY")], roots
)
try:
helpers["_check_binary_sources"](
[("foreign.dll", str(Path(temp_dir) / "foreign.dll"), "BINARY")], roots
)
except RuntimeError as exc:
assert "foreign.dll" in str(exc)
else:
raise AssertionError("Foreign DLL source must fail the build")
finally:
environment["PATH"] = original_path
def path_has_segment(path, x1: float, y1: float, x2: float, y2: float) -> bool: # noqa: ANN001 def path_has_segment(path, x1: float, y1: float, x2: float, y2: float) -> bool: # noqa: ANN001
for index in range(path.elementCount() - 1): for index in range(path.elementCount() - 1):
first = path.elementAt(index) first = path.elementAt(index)
@ -4682,5 +4939,249 @@ def fixture_points() -> list[dict[str, int]]:
return points return points
def test_text_comments_roundtrip_and_legacy() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "comments.json"
payload = {"circles": [], "coordinate_unit": "mm", "index_base": 0,
"custom": {"keep": True}, "comments": [
{"id": "note-1", "text": "Питание <24В>\nВторая строка 🔌",
"x": -12.125, "y": 34.75, "custom_style": {"color": "amber"}}]}
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
assert hasattr(doc, "comments"), "Документ должен загружать текстовые комментарии"
assert len(doc.comments) == 1
note = doc.comments[0]
assert (note.id, note.x, note.y, note.text) == ("note-1", -12.125, 34.75, payload["comments"][0]["text"])
doc.save(path)
saved = json.loads(path.read_text(encoding="utf-8"))
assert saved["comments"] == payload["comments"]
assert saved["custom"] == payload["custom"]
loaded = MappingDocument()
loaded.load(path)
assert loaded.to_payload()["comments"] == saved["comments"]
loaded.clear()
assert loaded.comments == []
path.write_text('{"circles": []}', encoding="utf-8")
loaded.load(path)
assert loaded.comments == []
def test_text_comments_validate_and_keep_identity() -> None:
doc = MappingDocument()
assert hasattr(doc, "add_comment"), "Нужны операции модели для комментариев"
first = doc.add_comment("Первый", -1.5, 2.75)
second = doc.add_comment("Второй", 3.0, -4.0)
doc.remove_comment(first.id)
assert doc.get_comment(second.id) is second
third = doc.add_comment("Третий", 0, 0)
assert len({first.id, second.id, third.id}) == 3
doc.update_comment(second.id, text="Изменён", x=-0.034, y=0.068)
assert (second.text, second.x, second.y) == ("Изменён", -0.034, 0.068)
for kwargs in ({"text": " "}, {"x": float("nan")}, {"y": float("inf")}):
before = deepcopy(second)
try:
doc.update_comment(second.id, **kwargs)
except ValueError:
pass
else:
raise AssertionError("Недопустимые комментарии должны отклоняться")
assert second == before
def test_text_comments_gui_history_and_cancel() -> None:
if MainWindow is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
try:
with patch("app.main_window.QInputDialog.getMultiLineText", return_value=("Заметка\n24В", True)):
window.add_comment_from_canvas(-34.5, 68.25)
assert len(window.document.comments) == 1
note_id = window.document.comments[0].id
assert window._dirty
assert window.actions["save"].isEnabled()
assert note_id in window.canvas.comment_items
with patch("app.main_window.QInputDialog.getMultiLineText", return_value=("Новый текст", True)):
window.edit_comment(note_id)
window.move_comment_from_canvas(note_id, 102.25, -136.5)
assert window.document.get_comment(note_id).x == 102.25
window.undo_last_change()
assert window.document.get_comment(note_id).x == -34.5
window.redo_last_change()
assert window.document.get_comment(note_id).x == 102.25
window.delete_comment(note_id)
assert window.document.comments == []
window.undo_last_change()
assert window.document.get_comment(note_id).text == "Новый текст"
before = len(window._undo_stack)
for result in (("Отмена", False), (" \n ", True)):
with patch("app.main_window.QInputDialog.getMultiLineText", return_value=result):
window.add_comment_from_canvas(0, 0)
window.edit_comment(note_id)
assert len(window._undo_stack) == before
assert len(window.document.comments) == 1
assert window.document.get_comment(note_id).text == "Новый текст"
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "only_comments.json"
assert window._save_to(path)
window.delete_comment(note_id)
assert window.actions["save"].isEnabled(), "Нужно сохранять удаление последнего комментария"
window.save_file()
loaded = MappingDocument()
loaded.load(path)
assert loaded.comments == []
finally:
window._dirty = False
window.close()
app.processEvents()
def test_text_comments_canvas_drag_and_units() -> None:
if MainWindow is None:
return
app = QApplication.instance() or QApplication([])
for unit, scale in (("mm", 1.0), ("m", 0.001)):
window = MainWindow()
try:
doc = MappingDocument()
doc.coordinate_unit = unit
doc.add_manual_point(0, 0)
doc.add_manual_point(340 * scale, 340 * scale)
note = doc.add_comment("Текст <b>без HTML</b>", 68 * scale, 136 * scale)
window.document = doc
window.canvas.set_document(doc)
window.show()
app.processEvents()
canvas = window.canvas
item = canvas.comment_items[note.id]
assert item.toPlainText() == note.text
assert item.pos() == canvas._to_scene_point(note.x, note.y)
before = len(window._undo_stack)
start = canvas.mapFromScene(item.pos()) + QPoint(12, 12)
end = start + QPoint(45, 30)
expected = canvas._document_point_from_scene(item.pos() + canvas.mapToScene(end) - canvas.mapToScene(start))
QTest.mousePress(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, start)
QTest.mouseMove(canvas.viewport(), end)
QTest.mouseRelease(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, end)
app.processEvents()
moved = window.document.get_comment(note.id)
assert abs(moved.x - expected.x()) < 1e-8
assert abs(moved.y - expected.y()) < 1e-8
assert len(window._undo_stack) == before + 1
window.undo_last_change()
restored = window.document.get_comment(note.id)
assert (restored.x, restored.y) == (68 * scale, 136 * scale)
item = canvas.comment_items[note.id]
at = canvas.mapFromScene(item.pos()) + QPoint(10, 10)
with patch("app.main_window.QInputDialog.getMultiLineText", return_value=("Двойной щелчок", True)):
QTest.mouseDClick(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, at)
assert window.document.get_comment(note.id).text == "Двойной щелчок"
canvas.set_instant_space_pan(True)
item = canvas.comment_items[note.id]
origin = QPointF(item.pos())
at = canvas.mapFromScene(origin) + QPoint(12, 12)
QTest.mousePress(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, at)
QTest.keyPress(canvas, Qt.Key_Space)
QTest.mouseMove(canvas.viewport(), at + QPoint(30, 20))
QTest.mouseRelease(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, at + QPoint(30, 20))
QTest.keyRelease(canvas, Qt.Key_Space)
assert canvas._comment_drag is None
QTest.mouseMove(canvas.viewport(), at + QPoint(60, 40))
assert item.pos() == origin
finally:
window._dirty = False
window.close()
app.processEvents()
def test_text_comments_fit_and_context_menu() -> None:
if LedCanvas is None:
return
app = QApplication.instance() or QApplication([])
canvas = LedCanvas()
canvas.resize(1000, 700)
doc = MappingDocument()
doc.add_manual_point(0, 0)
doc.add_manual_point(50000, 50000)
note = doc.add_comment("Дальний комментарий\nСтрока 2", 80000, 25000)
canvas.set_document(doc)
canvas.show()
app.processEvents()
canvas.fit_to_content()
item = canvas.comment_items[note.id]
visible_rect = item.deviceTransform(canvas.viewportTransform()).mapRect(item.boundingRect())
assert QRectF(canvas.viewport().rect()).adjusted(-2, -2, 2, 2).contains(visible_rect), visible_rect
from PySide6.QtGui import QContextMenuEvent
at = canvas.mapFromScene(item.pos()) + QPoint(8, 8)
emitted = []
canvas.editCommentRequested.connect(lambda key: emitted.append(("edit", key)))
canvas.deleteCommentRequested.connect(lambda key: emitted.append(("delete", key)))
original_menu = canvas_module.QMenu
class TestMenu(original_menu):
choice = 0
def exec(self, _pos):
return self.actions()[self.choice]
for index, expected in ((0, "edit"), (1, "delete")):
TestMenu.choice = index
with patch.object(canvas_module, "QMenu", TestMenu):
event = QContextMenuEvent(QContextMenuEvent.Mouse, at, canvas.viewport().mapToGlobal(at))
canvas.contextMenuEvent(event)
assert emitted[-1] == (expected, note.id)
canvas.addCommentRequested.connect(lambda x, y: emitted.append(("add", x, y)))
at = QPoint(30, 30)
expected = canvas._document_point_from_scene(canvas.mapToScene(at))
TestMenu.choice = 0
with patch.object(canvas_module, "QMenu", TestMenu):
canvas.contextMenuEvent(QContextMenuEvent(QContextMenuEvent.Mouse, at, canvas.viewport().mapToGlobal(at)))
assert emitted[-1] == ("add", expected.x(), expected.y())
canvas.close()
def test_text_comments_do_not_change_physical_mapping() -> None:
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
doc.add_block(line.id, [0, 1, 2])
before = generate_panel_mapping_from_document(doc, Path(temp_dir) / "before")
note = doc.add_comment("Контроль питания", -100.5, 200.25)
after = generate_panel_mapping_from_document(doc, Path(temp_dir) / "after")
assert set(after.generated_files) == set(before.generated_files)
assert {p.name for p in after.generated_files.values()} == {
"vm_map.h", "ordered_circles.json", "validation_report.txt", "README.md"}
assert not (after.output_dir / "virtual_matrix_legacy.ino.txt").exists()
first = json.loads((before.output_dir / "ordered_circles.json").read_text(encoding="utf-8"))
second = json.loads((after.output_dir / "ordered_circles.json").read_text(encoding="utf-8"))
assert first["circles"] == second["circles"]
assert after.assigned_count == before.assigned_count == 3
assert doc.get_comment(note.id).x == -100.5
header_before = (before.output_dir / "vm_map.h").read_text(encoding="utf-8")
header_after = (after.output_dir / "vm_map.h").read_text(encoding="utf-8")
assert header_before[header_before.index("#define VM_ROW_FROM_GRID_COL"):] == header_after[header_after.index("#define VM_ROW_FROM_GRID_COL"):]
def test_text_comments_only_fit_stays_stable() -> None:
if LedCanvas is None:
return
app = QApplication.instance() or QApplication([])
doc = MappingDocument()
doc.add_comment("Одна заметка", 10, 20)
canvas = LedCanvas()
canvas.resize(1000, 700)
canvas.set_document(doc)
canvas.show()
app.processEvents()
for _ in range(5):
canvas.fit_to_content()
assert 0.01 <= canvas.transform().m11() <= 2.0
canvas.close()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -44,7 +44,7 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
("brush", "Кисть", "B"), ("brush", "Кисть", "B"),
] ]
DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS} DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS}
SETTINGS_VERSION = 3 SETTINGS_VERSION = 4
SHORTCUT_DEFAULTS_MIGRATION_VERSION = 2 SHORTCUT_DEFAULTS_MIGRATION_VERSION = 2
DEFAULT_GRID_STEP_MM = 34.0 DEFAULT_GRID_STEP_MM = 34.0
MIN_GRID_STEP_MM = 1.0 MIN_GRID_STEP_MM = 1.0
@ -52,6 +52,9 @@ MAX_GRID_STEP_MM = 1000.0
DEFAULT_LED_POINT_DIAMETER_PX = 9 DEFAULT_LED_POINT_DIAMETER_PX = 9
MIN_LED_POINT_DIAMETER_PX = 1 MIN_LED_POINT_DIAMETER_PX = 1
MAX_LED_POINT_DIAMETER_PX = 64 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 @dataclass
@ -63,6 +66,7 @@ class AppSettings:
show_led_ids_by_default: bool = True show_led_ids_by_default: bool = True
grid_step_mm: float = DEFAULT_GRID_STEP_MM grid_step_mm: float = DEFAULT_GRID_STEP_MM
led_point_diameter_px: int = DEFAULT_LED_POINT_DIAMETER_PX 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 zoom_focus_pull: int = 35
pan_mode: str = "middle_space" pan_mode: str = "middle_space"
instant_space_pan: bool = False instant_space_pan: bool = False
@ -99,6 +103,12 @@ class AppSettings:
MAX_LED_POINT_DIAMETER_PX, MAX_LED_POINT_DIAMETER_PX,
DEFAULT_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.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) settings.brush_radius = clamp_int(settings.brush_radius, 4, 80, cls.brush_radius)
if settings.theme not in {"system", "light", "dark"}: if settings.theme not in {"system", "light", "dark"}:

View File

@ -30,11 +30,14 @@ try:
from .localization import localize_dialog_buttons, show_warning from .localization import localize_dialog_buttons, show_warning
from .settings import ( from .settings import (
DEFAULT_GRID_STEP_MM, DEFAULT_GRID_STEP_MM,
DEFAULT_LED_INDEX_FONT_SIZE_PX,
DEFAULT_LED_POINT_DIAMETER_PX, DEFAULT_LED_POINT_DIAMETER_PX,
DEFAULT_SHORTCUTS, DEFAULT_SHORTCUTS,
MAX_GRID_STEP_MM, MAX_GRID_STEP_MM,
MAX_LED_INDEX_FONT_SIZE_PX,
MAX_LED_POINT_DIAMETER_PX, MAX_LED_POINT_DIAMETER_PX,
MIN_GRID_STEP_MM, MIN_GRID_STEP_MM,
MIN_LED_INDEX_FONT_SIZE_PX,
MIN_LED_POINT_DIAMETER_PX, MIN_LED_POINT_DIAMETER_PX,
SETTINGS_VERSION, SETTINGS_VERSION,
SHORTCUT_DEFINITIONS, SHORTCUT_DEFINITIONS,
@ -44,11 +47,14 @@ except ImportError:
from localization import localize_dialog_buttons, show_warning from localization import localize_dialog_buttons, show_warning
from settings import ( from settings import (
DEFAULT_GRID_STEP_MM, DEFAULT_GRID_STEP_MM,
DEFAULT_LED_INDEX_FONT_SIZE_PX,
DEFAULT_LED_POINT_DIAMETER_PX, DEFAULT_LED_POINT_DIAMETER_PX,
DEFAULT_SHORTCUTS, DEFAULT_SHORTCUTS,
MAX_GRID_STEP_MM, MAX_GRID_STEP_MM,
MAX_LED_INDEX_FONT_SIZE_PX,
MAX_LED_POINT_DIAMETER_PX, MAX_LED_POINT_DIAMETER_PX,
MIN_GRID_STEP_MM, MIN_GRID_STEP_MM,
MIN_LED_INDEX_FONT_SIZE_PX,
MIN_LED_POINT_DIAMETER_PX, MIN_LED_POINT_DIAMETER_PX,
SETTINGS_VERSION, SETTINGS_VERSION,
SHORTCUT_DEFINITIONS, SHORTCUT_DEFINITIONS,
@ -95,6 +101,17 @@ class SettingsDialog(QDialog):
self.led_point_diameter_reset.clicked.connect( self.led_point_diameter_reset.clicked.connect(
lambda: self.led_point_diameter.setValue(DEFAULT_LED_POINT_DIAMETER_PX) 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 = QSpinBox()
self.zoom_pull.setRange(0, 100) self.zoom_pull.setRange(0, 100)
self.zoom_pull.setSuffix(" %") self.zoom_pull.setSuffix(" %")
@ -159,6 +176,7 @@ class SettingsDialog(QDialog):
show_led_ids_by_default=self.show_led_ids.isChecked(), show_led_ids_by_default=self.show_led_ids.isChecked(),
grid_step_mm=self.grid_step.value(), grid_step_mm=self.grid_step.value(),
led_point_diameter_px=self.led_point_diameter.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(), zoom_focus_pull=self.zoom_pull.value(),
pan_mode=str(self.pan_mode.currentData()), pan_mode=str(self.pan_mode.currentData()),
instant_space_pan=self.instant_space_pan.isChecked(), instant_space_pan=self.instant_space_pan.isChecked(),
@ -180,6 +198,10 @@ class SettingsDialog(QDialog):
"Диаметр LED-точек", "Диаметр LED-точек",
self._setting_with_reset(self.led_point_diameter, self.led_point_diameter_reset), 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("Притяжение зума к курсору", self.zoom_pull)
layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору.")) layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору."))
return page return page

View File

@ -164,10 +164,13 @@ def download_update(
target_dir = directory.resolve() target_dir = directory.resolve()
if not target_dir.is_dir(): if not target_dir.is_dir():
raise UpdateError("Каталог приложения не найден") 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 != ( if not _trusted_url(info.download_url) or urlparse(info.download_url).path != (
f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}" f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
): ):
raise UpdateError("Недопустимый адрес загрузки обновления") raise UpdateError("Недопустимый адрес загрузки обновления")
expected_path = f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
path: Path | None = None path: Path | None = None
received = 0 received = 0
request = Request(info.download_url, headers={"User-Agent": "LEDMapper-Updater"}) 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: with (opener or urlopen)(request, timeout=timeout) as response:
if not _trusted_url(response.geturl()): if not _trusted_url(response.geturl()):
raise UpdateError("Загрузка перенаправлена на другой сайт") 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( with tempfile.NamedTemporaryFile(
mode="wb", dir=target_dir, prefix=".ledmapper-update-", suffix=".download", delete=False mode="wb", dir=target_dir, prefix=".ledmapper-update-", suffix=".download", delete=False
) as output: ) as output:
@ -296,6 +312,8 @@ def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None
source = downloaded.resolve() source = downloaded.resolve()
if platform.system() != "Windows": if platform.system() != "Windows":
raise UpdateError("Установка обновления доступна только в 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-"): if source.parent != old_target.parent or not source.is_file() or not source.name.startswith(".ledmapper-update-"):
raise UpdateError("Скачанный файл должен находиться рядом с приложением") raise UpdateError("Скачанный файл должен находиться рядом с приложением")
if not EXECUTABLE_RE.fullmatch(info.asset_name): 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}" ready_path = old_target.parent / f".ledmapper-ready-{token}"
try: try:
_write_windows_updater(script_path) _write_windows_updater(script_path)
creation_flags = (
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
subprocess.Popen( subprocess.Popen(
[ [
"powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "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), str(new_target), str(backup_path), str(ready_path),
], ],
close_fds=True, close_fds=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), creationflags=creation_flags,
) )
except (OSError, UpdateError) as exc: except (OSError, UpdateError) as exc:
script_path.unlink(missing_ok=True) script_path.unlink(missing_ok=True)

View File

@ -1,3 +1,3 @@
"""Текущая версия приложения; обновляется при публикации релиза.""" """Текущая версия приложения; обновляется при публикации релиза."""
__version__ = "3.3.0" __version__ = "3.5.0"