Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30ac54f77f | |||
| 25ca2101a4 | |||
| 37fbb2c545 | |||
| f8f6a216f5 |
@ -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
|
||||||
|
|
||||||
@ -10,15 +12,15 @@ spec_dir = Path(SPECPATH)
|
|||||||
|
|
||||||
def git_build_ref():
|
def git_build_ref():
|
||||||
try:
|
try:
|
||||||
dirty = subprocess.run(
|
dirty = subprocess.check_output(
|
||||||
['git', 'diff-index', '--quiet', 'HEAD', '--'],
|
['git', 'status', '--porcelain', '--untracked-files=normal'],
|
||||||
cwd=spec_dir,
|
cwd=spec_dir,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
stdout=subprocess.DEVNULL,
|
text=True,
|
||||||
).returncode != 0
|
)
|
||||||
if dirty:
|
if dirty.strip():
|
||||||
return datetime.now().strftime('%H%M%S-%d%m%Y')
|
return datetime.now().strftime('%H%M%S-%d%m%Y')
|
||||||
except OSError:
|
except (OSError, subprocess.CalledProcessError):
|
||||||
return datetime.now().strftime('%H%M%S-%d%m%Y')
|
return datetime.now().strftime('%H%M%S-%d%m%Y')
|
||||||
try:
|
try:
|
||||||
tag = subprocess.check_output(
|
tag = subprocess.check_output(
|
||||||
@ -45,16 +47,54 @@ 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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
build_version_path.write_text(app_name.removeprefix('LEDMapper-'), encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
[str(spec_dir / 'main.py')],
|
[str(spec_dir / 'main.py')],
|
||||||
pathex=[str(spec_dir), str(spec_dir.parent)],
|
pathex=[str(spec_dir), str(spec_dir.parent)],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[(str(spec_dir / 'assets'), 'assets')],
|
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',
|
||||||
@ -66,6 +106,8 @@ a = Analysis(
|
|||||||
'settings_dialog',
|
'settings_dialog',
|
||||||
'theme',
|
'theme',
|
||||||
'unfolding',
|
'unfolding',
|
||||||
|
'update_service',
|
||||||
|
'version',
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@ -74,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(
|
||||||
|
|||||||
37
README.md
37
README.md
@ -10,6 +10,7 @@ LED Mapper — настольное приложение на Python и PySide6
|
|||||||
- Отображение схемы в реальных координатах.
|
- Отображение схемы в реальных координатах.
|
||||||
- Выделение точек мышью и работа через таблицу выбранных LED.
|
- Выделение точек мышью и работа через таблицу выбранных LED.
|
||||||
- До 4 физических линий светодиодных лент.
|
- До 4 физических линий светодиодных лент.
|
||||||
|
- Нумерация линий и блоков с `0`.
|
||||||
- Блоки внутри линий, включая пустые блоки.
|
- Блоки внутри линий, включая пустые блоки.
|
||||||
- Индексация LED отдельно для каждой линии, начиная с `0`.
|
- Индексация LED отдельно для каждой линии, начиная с `0`.
|
||||||
- Индексация внутри каждого блока.
|
- Индексация внутри каждого блока.
|
||||||
@ -17,14 +18,16 @@ LED Mapper — настольное приложение на Python и PySide6
|
|||||||
- Продолжение существующего блока кистью от последней точки блока.
|
- Продолжение существующего блока кистью от последней точки блока.
|
||||||
- Предпросмотр зоны действия кисти и точек, которые будут добавлены.
|
- Предпросмотр зоны действия кисти и точек, которые будут добавлены.
|
||||||
- Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации.
|
- Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации.
|
||||||
- Настраиваемый абсолютный диаметр LED-точек: визуальный размер не меняется при приближении.
|
- Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении.
|
||||||
- Undo/redo для операций разметки.
|
- Свободные многострочные текстовые комментарии на схеме.
|
||||||
|
- Undo/redo для операций разметки и комментариев.
|
||||||
- Удаление назначений через `Del` и контекстные меню.
|
- Удаление назначений через `Del` и контекстные меню.
|
||||||
- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo.
|
- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo.
|
||||||
- Сворачивание боковой панели.
|
- Сворачивание боковой панели.
|
||||||
- Сортировка таблицы выбранных точек по столбцам.
|
- Сортировка таблицы выбранных точек по столбцам.
|
||||||
- Генерация `vm_map.h`, `ordered_circles.json`, `validation_report.txt` и README для результата.
|
- Генерация `vm_map.h`, `ordered_circles.json`, `validation_report.txt` и README для результата.
|
||||||
- Сборка самостоятельного Windows EXE через PyInstaller.
|
- Сборка самостоятельного Windows EXE через PyInstaller.
|
||||||
|
- Проверка и установка Windows-обновлений из релизов Gitea.
|
||||||
|
|
||||||
## Структура приложения
|
## Структура приложения
|
||||||
|
|
||||||
@ -34,6 +37,8 @@ main_window.py Главное окно, меню, действия,
|
|||||||
canvas.py Холст схемы, выделение, кисть, сетка и связи
|
canvas.py Холст схемы, выделение, кисть, сетка и связи
|
||||||
models.py Модель карты, линий, блоков и алгоритмы маршрутов
|
models.py Модель карты, линий, блоков и алгоритмы маршрутов
|
||||||
panel_mapping_generator.py Генерация файлов маппинга
|
panel_mapping_generator.py Генерация файлов маппинга
|
||||||
|
update_service.py Проверка релизов и безопасная установка Windows EXE
|
||||||
|
version.py Базовая версия приложения при запуске из исходников
|
||||||
icons.py Встроенные lucide-иконки
|
icons.py Встроенные lucide-иконки
|
||||||
unfolding.py Описание развертки линий и блоков
|
unfolding.py Описание развертки линий и блоков
|
||||||
qa_smoke.py Smoke-тесты приложения
|
qa_smoke.py Smoke-тесты приложения
|
||||||
@ -85,14 +90,28 @@ python -m venv .venv
|
|||||||
6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED.
|
6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED.
|
||||||
7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`.
|
7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`.
|
||||||
|
|
||||||
Шаг сетки и диаметр точек меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек — в пользовательских параметрах приложения. Изменение шага не перемещает уже существующие LED автоматически.
|
Шаг сетки, диаметр точек и размер текста индексов меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Размер текста индексов обновляется на схеме сразу во время настройки; при отмене возвращается прежнее значение. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек и размер текста индексов — в пользовательских параметрах приложения. Визуальные размеры точек и подписей не меняются при приближении схемы. Изменение шага не перемещает уже существующие LED автоматически.
|
||||||
|
|
||||||
Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo.
|
Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo.
|
||||||
|
|
||||||
В режиме редактирования выбранные светодиоды можно скопировать через `Ctrl+C` и вставить через `Ctrl+V`. Вставленные LED остаются свободными, сохраняют взаимное расположение и пользовательские поля исходных точек. Первая копия смещается на один шаг сетки вправо и вниз, а следующие вставки образуют каскад. Вся группа вставляется одной операцией Undo и сразу становится текущим выделением.
|
В режиме редактирования выбранные светодиоды можно скопировать через `Ctrl+C` и вставить через `Ctrl+V`. Вставленные LED остаются свободными, сохраняют взаимное расположение и пользовательские поля исходных точек. Первая копия смещается на один шаг сетки вправо и вниз, а следующие вставки образуют каскад. Вся группа вставляется одной операцией Undo и сразу становится текущим выделением.
|
||||||
|
|
||||||
|
В собранном 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 и отображаются в редакторе.
|
||||||
|
|
||||||
## Горячие клавиши
|
## Горячие клавиши
|
||||||
|
|
||||||
| Действие | Клавиши |
|
| Действие | Клавиши |
|
||||||
@ -128,13 +147,13 @@ python -m venv .venv
|
|||||||
|
|
||||||
Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся. Если файл с этим точным именем остался в выходной папке от прежней версии, следующая успешная генерация удалит его. Другие `.ino`-файлы не затрагиваются.
|
Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся. Если файл с этим точным именем остался в выходной папке от прежней версии, следующая успешная генерация удалит его. Другие `.ino`-файлы не затрагиваются.
|
||||||
|
|
||||||
JSON схемы хранит `coordinate_unit` и `grid_step_mm`. При первом открытии старого файла без единицы координат приложение определяет её один раз и затем сохраняет явно. Сохранение выполняется через временный файл и атомарную замену, поэтому исходный 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:
|
||||||
|
|
||||||
- `strip = line_id - 1`
|
- `strip = line_id`
|
||||||
- `led_id = line_index`
|
- `led_id = line_index`
|
||||||
|
|
||||||
Неназначенные точки не попадают в рабочий `VM_MAPPINGS`, но исходные точки могут оставаться в служебных метаданных.
|
Неназначенные точки не попадают в рабочий `VM_MAPPINGS`, но исходные точки могут оставаться в служебных метаданных.
|
||||||
@ -178,10 +197,12 @@ JSON схемы хранит `coordinate_unit` и `grid_step_mm`. При пер
|
|||||||
.\.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
|
||||||
dist/LEDMapper.exe
|
dist/LEDMapper-vX.Y.Z.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
`build/` и `dist/` не коммитятся, потому что это воспроизводимые артефакты сборки.
|
`build/` и `dist/` не коммитятся, потому что это воспроизводимые артефакты сборки.
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
"""LED Mapper Editor application package."""
|
"""LED Mapper Editor application package."""
|
||||||
|
|
||||||
|
from .version import __version__
|
||||||
|
|||||||
@ -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
147
background_image_loader.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QSize, Qt
|
||||||
|
from PySide6.QtGui import QImage, QImageIOHandler, QImageReader
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .models import BackgroundImage
|
||||||
|
except ImportError:
|
||||||
|
from models import BackgroundImage
|
||||||
|
|
||||||
|
|
||||||
|
MAX_BACKGROUND_IMAGE_SIDE = 8192
|
||||||
|
MAX_BACKGROUND_IMAGE_PIXELS = 32_000_000
|
||||||
|
MAX_UNSCALED_SOURCE_PIXELS = 100_000_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackgroundImageLoadResult:
|
||||||
|
image: QImage
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loaded(self) -> bool:
|
||||||
|
return not self.image.isNull()
|
||||||
|
|
||||||
|
|
||||||
|
def supported_image_filter() -> str:
|
||||||
|
formats = sorted(
|
||||||
|
{
|
||||||
|
bytes(image_format).decode("ascii", errors="ignore").lower()
|
||||||
|
for image_format in QImageReader.supportedImageFormats()
|
||||||
|
if image_format
|
||||||
|
}
|
||||||
|
)
|
||||||
|
wildcards: list[str] = []
|
||||||
|
for image_format in formats:
|
||||||
|
if not image_format or image_format == "pdf":
|
||||||
|
continue
|
||||||
|
wildcards.extend((f"*.{image_format}", f"*.{image_format.upper()}"))
|
||||||
|
patterns = " ".join(dict.fromkeys(wildcards))
|
||||||
|
return f"Изображения ({patterns});;Все файлы (*.*)"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_background_path(background: BackgroundImage, document_path: Path | None = None) -> Path:
|
||||||
|
path = Path(background.path)
|
||||||
|
if not path.is_absolute() and document_path is not None:
|
||||||
|
path = document_path.parent / path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def background_cache_key(background: BackgroundImage, document_path: Path | None = None) -> tuple[object, ...]:
|
||||||
|
if background.has_embedded_image():
|
||||||
|
image_bytes = background.embedded_image_bytes()
|
||||||
|
return ("embedded", background.embedded_image_sha256 or hash(image_bytes), len(image_bytes))
|
||||||
|
path = resolve_background_path(background, document_path)
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except OSError:
|
||||||
|
return ("file", str(path), None, None)
|
||||||
|
return ("file", str(path), stat.st_mtime_ns, stat.st_size)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_image_size(size: QSize, max_side: int) -> QSize:
|
||||||
|
if not size.isValid() or max_side <= 0:
|
||||||
|
return size
|
||||||
|
side_factor = min(1.0, max_side / max(size.width(), size.height()))
|
||||||
|
pixel_factor = min(1.0, math.sqrt(MAX_BACKGROUND_IMAGE_PIXELS / (size.width() * size.height())))
|
||||||
|
factor = min(side_factor, pixel_factor)
|
||||||
|
if factor >= 1.0:
|
||||||
|
return size
|
||||||
|
return QSize(max(1, math.floor(size.width() * factor)), max(1, math.floor(size.height() * factor)))
|
||||||
|
|
||||||
|
|
||||||
|
def load_background_image(
|
||||||
|
background: BackgroundImage,
|
||||||
|
document_path: Path | None = None,
|
||||||
|
*,
|
||||||
|
max_side: int = MAX_BACKGROUND_IMAGE_SIDE,
|
||||||
|
) -> BackgroundImageLoadResult:
|
||||||
|
buffer: QBuffer | None = None
|
||||||
|
if background.has_embedded_image():
|
||||||
|
image_bytes = background.embedded_image_bytes()
|
||||||
|
if not image_bytes:
|
||||||
|
return BackgroundImageLoadResult(QImage(), "Встроенные данные изображения повреждены или пусты.")
|
||||||
|
buffer = QBuffer()
|
||||||
|
buffer.setData(QByteArray(image_bytes))
|
||||||
|
if not buffer.open(QIODevice.OpenModeFlag.ReadOnly):
|
||||||
|
return BackgroundImageLoadResult(QImage(), "Не удалось открыть встроенные данные изображения.")
|
||||||
|
reader = QImageReader(buffer)
|
||||||
|
source_name = background.embedded_image_name or "встроенное изображение"
|
||||||
|
else:
|
||||||
|
path = resolve_background_path(background, document_path)
|
||||||
|
if not path.is_file():
|
||||||
|
return BackgroundImageLoadResult(QImage(), f"Файл не найден: {path}")
|
||||||
|
reader = QImageReader(str(path))
|
||||||
|
source_name = path.name
|
||||||
|
|
||||||
|
previous_allocation_limit: int | None = None
|
||||||
|
try:
|
||||||
|
reader.setAutoTransform(True)
|
||||||
|
reader.setDecideFormatFromContent(True)
|
||||||
|
size = reader.size()
|
||||||
|
bounded_size = _bounded_image_size(size, max_side)
|
||||||
|
if bounded_size != size:
|
||||||
|
if not reader.supportsOption(QImageIOHandler.ImageOption.ScaledSize):
|
||||||
|
source_pixels = size.width() * size.height()
|
||||||
|
if source_pixels > MAX_UNSCALED_SOURCE_PIXELS:
|
||||||
|
return BackgroundImageLoadResult(
|
||||||
|
QImage(),
|
||||||
|
f"Изображение «{source_name}» слишком большое для безопасного декодирования "
|
||||||
|
f"({size.width()}×{size.height()} пикселей).",
|
||||||
|
)
|
||||||
|
current_allocation_limit = QImageReader.allocationLimit()
|
||||||
|
required_megabytes = math.ceil(source_pixels * 8 / (1024 * 1024)) + 16
|
||||||
|
if current_allocation_limit > 0 and required_megabytes > current_allocation_limit:
|
||||||
|
previous_allocation_limit = current_allocation_limit
|
||||||
|
QImageReader.setAllocationLimit(required_megabytes)
|
||||||
|
reader.setScaledSize(bounded_size)
|
||||||
|
image = reader.read()
|
||||||
|
if image.isNull():
|
||||||
|
details = reader.errorString().strip()
|
||||||
|
message = f"Не удалось декодировать «{source_name}»"
|
||||||
|
if details:
|
||||||
|
message += f": {details}"
|
||||||
|
return BackgroundImageLoadResult(QImage(), message + ".")
|
||||||
|
|
||||||
|
bounded_size = _bounded_image_size(image.size(), max_side)
|
||||||
|
if bounded_size != image.size():
|
||||||
|
image = image.scaled(
|
||||||
|
bounded_size,
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
if image.format() not in (QImage.Format.Format_ARGB32, QImage.Format.Format_ARGB32_Premultiplied):
|
||||||
|
image = image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
|
||||||
|
if image.isNull():
|
||||||
|
return BackgroundImageLoadResult(QImage(), f"Не удалось преобразовать «{source_name}» для отображения.")
|
||||||
|
return BackgroundImageLoadResult(image)
|
||||||
|
finally:
|
||||||
|
if previous_allocation_limit is not None:
|
||||||
|
QImageReader.setAllocationLimit(previous_allocation_limit)
|
||||||
|
if buffer is not None:
|
||||||
|
buffer.close()
|
||||||
228
canvas.py
228
canvas.py
@ -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,12 +774,12 @@ 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()
|
||||||
|
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
|
||||||
@ -688,8 +787,13 @@ class LedCanvas(QGraphicsView):
|
|||||||
rect = rect.united(background_rect)
|
rect = rect.united(background_rect)
|
||||||
if rect is None or rect.isEmpty():
|
if rect is None or rect.isEmpty():
|
||||||
rect = self.sceneRect()
|
rect = self.sceneRect()
|
||||||
|
if rect.isEmpty():
|
||||||
|
return
|
||||||
self.fitInView(rect, Qt.KeepAspectRatio)
|
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)
|
||||||
|
|||||||
13
docs/superpowers/plans/2026-11-05-text-comments.md
Normal file
13
docs/superpowers/plans/2026-11-05-text-comments.md
Normal 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. Работа ведётся поверх текущих файлов без отката.
|
||||||
7
main.py
7
main.py
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
APP_DIR = Path(__file__).resolve().parent
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
@ -14,15 +15,20 @@ try:
|
|||||||
from .main_window import MainWindow
|
from .main_window import MainWindow
|
||||||
from .settings import AppSettings
|
from .settings import AppSettings
|
||||||
from .theme import apply_app_theme, effective_theme
|
from .theme import apply_app_theme, effective_theme
|
||||||
|
from .update_service import signal_update_ready
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from icons import lucide_icon
|
from icons import lucide_icon
|
||||||
from main_window import MainWindow
|
from main_window import MainWindow
|
||||||
from settings import AppSettings
|
from settings import AppSettings
|
||||||
from theme import apply_app_theme, effective_theme
|
from theme import apply_app_theme, effective_theme
|
||||||
|
from update_service import signal_update_ready
|
||||||
|
|
||||||
|
|
||||||
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"
|
||||||
@ -31,6 +37,7 @@ def main() -> int:
|
|||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.setWindowIcon(app_icon)
|
window.setWindowIcon(app_icon)
|
||||||
window.show()
|
window.show()
|
||||||
|
QTimer.singleShot(500, signal_update_ready)
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
249
main_window.py
249
main_window.py
@ -2,9 +2,10 @@ from __future__ import annotations
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
from threading import Event, Thread
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QSignalBlocker, QSize, Qt, QTimer
|
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,
|
||||||
@ -61,6 +62,10 @@ try:
|
|||||||
from .theme import apply_app_theme, effective_theme
|
from .theme import apply_app_theme, effective_theme
|
||||||
from .unfolding import MAX_LINES
|
from .unfolding import MAX_LINES
|
||||||
from .mapping_renderer import render_mapping_image
|
from .mapping_renderer import render_mapping_image
|
||||||
|
from .update_service import (
|
||||||
|
UpdateCancelled, UpdateError, UpdateInfo, check_for_update, current_executable, current_version,
|
||||||
|
download_update, launch_update,
|
||||||
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from background_dialog import BackgroundDialog
|
from background_dialog import BackgroundDialog
|
||||||
from canvas import LedCanvas
|
from canvas import LedCanvas
|
||||||
@ -80,6 +85,10 @@ except ImportError:
|
|||||||
from theme import apply_app_theme, effective_theme
|
from theme import apply_app_theme, effective_theme
|
||||||
from unfolding import MAX_LINES
|
from unfolding import MAX_LINES
|
||||||
from mapping_renderer import render_mapping_image
|
from mapping_renderer import render_mapping_image
|
||||||
|
from update_service import (
|
||||||
|
UpdateCancelled, UpdateError, UpdateInfo, check_for_update, current_executable, current_version,
|
||||||
|
download_update, launch_update,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||||
@ -191,6 +200,13 @@ class GenerationStrategyDialog(QDialog):
|
|||||||
self.ok_button.setEnabled(True)
|
self.ok_button.setEnabled(True)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSignals(QObject):
|
||||||
|
checked = Signal(object)
|
||||||
|
downloaded = Signal(object)
|
||||||
|
progress = Signal(int)
|
||||||
|
failed = Signal(str)
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@ -231,6 +247,16 @@ class MainWindow(QMainWindow):
|
|||||||
self.menu_buttons_widget: QWidget | None = None
|
self.menu_buttons_widget: QWidget | None = None
|
||||||
self.right_panel: QFrame | None = None
|
self.right_panel: QFrame | None = None
|
||||||
self.right_panel_buttons: dict[str, tuple[QToolButton, str]] = {}
|
self.right_panel_buttons: dict[str, tuple[QToolButton, str]] = {}
|
||||||
|
self._update_info: UpdateInfo | None = None
|
||||||
|
self._update_busy = False
|
||||||
|
self._update_manual_check = False
|
||||||
|
self._update_cancel = Event()
|
||||||
|
self._installing_update = False
|
||||||
|
self._update_signals = UpdateSignals(self)
|
||||||
|
self._update_signals.checked.connect(self._on_update_checked)
|
||||||
|
self._update_signals.downloaded.connect(self._on_update_downloaded)
|
||||||
|
self._update_signals.progress.connect(self._on_update_progress)
|
||||||
|
self._update_signals.failed.connect(self._on_update_failed)
|
||||||
|
|
||||||
self.canvas = LedCanvas()
|
self.canvas = LedCanvas()
|
||||||
self._apply_canvas_settings()
|
self._apply_canvas_settings()
|
||||||
@ -243,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)
|
||||||
@ -321,6 +351,8 @@ class MainWindow(QMainWindow):
|
|||||||
application.focusChanged.connect(self._on_application_focus_changed)
|
application.focusChanged.connect(self._on_application_focus_changed)
|
||||||
application.installEventFilter(self)
|
application.installEventFilter(self)
|
||||||
self._refresh_ui_state()
|
self._refresh_ui_state()
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
QTimer.singleShot(1200, self.check_updates)
|
||||||
|
|
||||||
def _build_actions(self) -> None:
|
def _build_actions(self) -> None:
|
||||||
def add_action(
|
def add_action(
|
||||||
@ -346,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)
|
||||||
@ -359,6 +392,9 @@ class MainWindow(QMainWindow):
|
|||||||
"Экспортировать текущую схему маппинга в изображение (PNG, JPEG)"
|
"Экспортировать текущую схему маппинга в изображение (PNG, JPEG)"
|
||||||
)
|
)
|
||||||
add_action("settings", "Параметры", self.open_settings, "settings", QKeySequence("Ctrl+,"))
|
add_action("settings", "Параметры", self.open_settings, "settings", QKeySequence("Ctrl+,"))
|
||||||
|
add_action("check_updates", "Проверить обновления", self.check_updates)
|
||||||
|
add_action("install_update", "Установить обновление", self.install_update)
|
||||||
|
self.actions["install_update"].setEnabled(False)
|
||||||
add_action(
|
add_action(
|
||||||
"generate_script",
|
"generate_script",
|
||||||
"Сгенерировать скрипт",
|
"Сгенерировать скрипт",
|
||||||
@ -522,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"])
|
||||||
@ -529,6 +566,10 @@ class MainWindow(QMainWindow):
|
|||||||
tools_menu.addSeparator()
|
tools_menu.addSeparator()
|
||||||
tools_menu.addAction(self.actions["brush"])
|
tools_menu.addAction(self.actions["brush"])
|
||||||
|
|
||||||
|
help_menu = menu_bar.addMenu("Справка")
|
||||||
|
help_menu.addAction(self.actions["check_updates"])
|
||||||
|
help_menu.addAction(self.actions["install_update"])
|
||||||
|
|
||||||
# Delete old widget if recreating
|
# Delete old widget if recreating
|
||||||
if hasattr(self, "menu_buttons_widget") and self.menu_buttons_widget is not None:
|
if hasattr(self, "menu_buttons_widget") and self.menu_buttons_widget is not None:
|
||||||
self.menu_buttons_widget.deleteLater()
|
self.menu_buttons_widget.deleteLater()
|
||||||
@ -568,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()
|
||||||
@ -991,13 +1040,9 @@ 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
|
|
||||||
reader = QImageReader(str(image_path))
|
|
||||||
if not reader.canRead():
|
|
||||||
show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.")
|
|
||||||
return
|
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("Подложка не изменилась.")
|
||||||
@ -1083,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
|
||||||
@ -1133,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)
|
||||||
@ -1675,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)
|
||||||
@ -2795,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),
|
||||||
@ -2815,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,
|
||||||
@ -3059,8 +3157,117 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
return answer == QMessageBox.StandardButton.Yes
|
return answer == QMessageBox.StandardButton.Yes
|
||||||
|
|
||||||
|
def check_updates(self) -> None:
|
||||||
|
if self._update_busy:
|
||||||
|
return
|
||||||
|
self._update_manual_check = not bool(getattr(sys, "frozen", False)) or self.sender() is self.actions["check_updates"]
|
||||||
|
self._update_busy = True
|
||||||
|
self.actions["check_updates"].setEnabled(False)
|
||||||
|
self.actions["install_update"].setEnabled(False)
|
||||||
|
if self._update_manual_check:
|
||||||
|
self.status.setText("Проверка обновлений...")
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
try:
|
||||||
|
self._update_signals.checked.emit(check_for_update(current_version()))
|
||||||
|
except (UpdateError, OSError) as exc:
|
||||||
|
self._update_signals.failed.emit(str(exc))
|
||||||
|
|
||||||
|
Thread(target=worker, name="ledmapper-update-check", daemon=True).start()
|
||||||
|
|
||||||
|
def _on_update_checked(self, info: UpdateInfo | None) -> None:
|
||||||
|
self._update_busy = False
|
||||||
|
self._update_info = info
|
||||||
|
self.actions["check_updates"].setEnabled(True)
|
||||||
|
self.actions["install_update"].setEnabled(info is not None and bool(getattr(sys, "frozen", False)))
|
||||||
|
if info is None:
|
||||||
|
if self._update_manual_check:
|
||||||
|
show_information(self, "Обновления", f"Установлена актуальная версия {current_version()}.")
|
||||||
|
return
|
||||||
|
self.actions["install_update"].setText(f"Установить обновление {info.tag}")
|
||||||
|
self.status.setText(f"Доступна версия {info.tag}. Меню «Справка» → «Установить обновление».")
|
||||||
|
if not getattr(sys, "frozen", False) and self._update_manual_check:
|
||||||
|
show_information(self, "Обновление", "Автоустановка доступна только в собранном EXE.")
|
||||||
|
|
||||||
|
def install_update(self) -> None:
|
||||||
|
info = self._update_info
|
||||||
|
if info is None or self._update_busy:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
executable = current_executable()
|
||||||
|
except UpdateError as exc:
|
||||||
|
show_warning(self, "Обновление", str(exc))
|
||||||
|
return
|
||||||
|
answer = ask_question(self, "Обновление LED Mapper", f"Скачать {info.asset_name} и перезапустить приложение?")
|
||||||
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
self._update_busy = True
|
||||||
|
self._update_cancel.clear()
|
||||||
|
self.actions["check_updates"].setEnabled(False)
|
||||||
|
self.actions["install_update"].setEnabled(False)
|
||||||
|
self.status.setText(f"Загрузка {info.asset_name}: 0 %")
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
try:
|
||||||
|
downloaded = download_update(
|
||||||
|
info, executable.parent,
|
||||||
|
progress=self._update_signals.progress.emit,
|
||||||
|
cancelled=self._update_cancel.is_set,
|
||||||
|
)
|
||||||
|
if self._update_cancel.is_set():
|
||||||
|
downloaded.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
self._update_signals.downloaded.emit(downloaded)
|
||||||
|
except UpdateCancelled:
|
||||||
|
return
|
||||||
|
except (UpdateError, OSError) as exc:
|
||||||
|
self._update_signals.failed.emit(str(exc))
|
||||||
|
|
||||||
|
Thread(target=worker, name="ledmapper-update-download", daemon=True).start()
|
||||||
|
|
||||||
|
def _on_update_progress(self, percent: int) -> None:
|
||||||
|
if self._update_info is not None:
|
||||||
|
self.status.setText(f"Загрузка {self._update_info.asset_name}: {percent} %")
|
||||||
|
|
||||||
|
def _on_update_downloaded(self, downloaded: Path) -> None:
|
||||||
|
self._update_busy = False
|
||||||
|
self.actions["check_updates"].setEnabled(True)
|
||||||
|
self.actions["install_update"].setEnabled(True)
|
||||||
|
info = self._update_info
|
||||||
|
if info is None:
|
||||||
|
downloaded.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
if self._dirty:
|
||||||
|
answer = ask_question(self, "Сохранить схему", "Перед перезапуском нужно сохранить изменения схемы. Сохранить сейчас?")
|
||||||
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
|
downloaded.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
self.save_file()
|
||||||
|
if self._dirty:
|
||||||
|
downloaded.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
launch_update(downloaded, info)
|
||||||
|
except UpdateError as exc:
|
||||||
|
downloaded.unlink(missing_ok=True)
|
||||||
|
show_warning(self, "Обновление", str(exc))
|
||||||
|
return
|
||||||
|
self._installing_update = True
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _on_update_failed(self, message: str) -> None:
|
||||||
|
self._update_busy = False
|
||||||
|
self.actions["check_updates"].setEnabled(True)
|
||||||
|
self.actions["install_update"].setEnabled(
|
||||||
|
self._update_info is not None and bool(getattr(sys, "frozen", False))
|
||||||
|
)
|
||||||
|
self.status.setText(f"Ошибка обновления: {message}")
|
||||||
|
if self._update_manual_check or self._update_info is not None:
|
||||||
|
show_warning(self, "Обновление", message)
|
||||||
|
|
||||||
def closeEvent(self, event) -> None: # noqa: ANN001
|
def closeEvent(self, event) -> None: # noqa: ANN001
|
||||||
if self._confirm_discard_changes("Закрыть приложение и потерять несохраненные изменения текущей карты?"):
|
if self._installing_update or self._confirm_discard_changes("Закрыть приложение и потерять несохраненные изменения текущей карты?"):
|
||||||
|
self._update_cancel.set()
|
||||||
event.accept()
|
event.accept()
|
||||||
else:
|
else:
|
||||||
event.ignore()
|
event.ignore()
|
||||||
|
|||||||
141
models.py
141
models.py
@ -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,9 +27,11 @@ 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",
|
||||||
|
"index_base",
|
||||||
"leds",
|
"leds",
|
||||||
"lines",
|
"lines",
|
||||||
"unit",
|
"unit",
|
||||||
@ -310,32 +313,66 @@ 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"
|
||||||
self.grid_step_mm = DEFAULT_GRID_STEP_MM
|
self.grid_step_mm = DEFAULT_GRID_STEP_MM
|
||||||
self.extra_payload: dict[str, Any] = {}
|
self.extra_payload: dict[str, Any] = {}
|
||||||
self._next_line_id = 1
|
self._next_line_id = 0
|
||||||
self._next_block_id = 1
|
self._next_block_id = 0
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
self.path = None
|
self.path = None
|
||||||
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"
|
||||||
self.grid_step_mm = DEFAULT_GRID_STEP_MM
|
self.grid_step_mm = DEFAULT_GRID_STEP_MM
|
||||||
self.extra_payload.clear()
|
self.extra_payload.clear()
|
||||||
self._next_line_id = 1
|
self._next_line_id = 0
|
||||||
self._next_block_id = 1
|
self._next_block_id = 0
|
||||||
|
|
||||||
def load(self, path: str | Path) -> None:
|
def load(self, path: str | Path) -> None:
|
||||||
self.clear()
|
self.clear()
|
||||||
@ -384,8 +421,21 @@ 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.background = BackgroundImage.from_payload(data.get("background"))
|
self.background = BackgroundImage.from_payload(data.get("background"))
|
||||||
self.unfolding = UnfoldingDescription.from_payload(data.get("unfolding"))
|
self.unfolding = UnfoldingDescription.from_payload(data.get("unfolding"))
|
||||||
self.unfolding.max_lines = MAX_LINES
|
self.unfolding.max_lines = MAX_LINES
|
||||||
@ -424,6 +474,41 @@ class MappingDocument:
|
|||||||
self.lines.append(line)
|
self.lines.append(line)
|
||||||
self._next_line_id = max(self._next_line_id, line_id + 1)
|
self._next_line_id = max(self._next_line_id, line_id + 1)
|
||||||
|
|
||||||
|
def _migrate_legacy_index_base(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Однократно переводит старые 1-based ID, сохраняя физические адреса лент."""
|
||||||
|
marker = data.get("index_base")
|
||||||
|
if marker == 0:
|
||||||
|
return
|
||||||
|
if marker not in (None, 1):
|
||||||
|
raise ValueError("Unsupported mapping index_base; expected 0 or 1.")
|
||||||
|
identifiers = [line.id for line in self.lines]
|
||||||
|
identifiers.extend(point.line_id for point in self.points if point.line_id is not None)
|
||||||
|
if marker is None and (not identifiers or min(identifiers) == 0):
|
||||||
|
return
|
||||||
|
if identifiers and min(identifiers) < 1:
|
||||||
|
return
|
||||||
|
for line in self.lines:
|
||||||
|
old_id = line.id
|
||||||
|
line.id -= 1
|
||||||
|
if re.fullmatch(r"(?:Линия|Line)\s+\d+", line.name):
|
||||||
|
line.name = f"Линия {line.id}"
|
||||||
|
if line.color == default_color(old_id):
|
||||||
|
line.color = default_color(line.id)
|
||||||
|
for block in line.blocks:
|
||||||
|
block.id -= 1
|
||||||
|
if re.fullmatch(r"(?:Блок|Block)\s+\d+", block.name):
|
||||||
|
block.name = f"Блок {block.id}"
|
||||||
|
for point in self.points:
|
||||||
|
if point.line_id is not None:
|
||||||
|
point.line_id -= 1
|
||||||
|
if point.block_id is not None:
|
||||||
|
point.block_id -= 1
|
||||||
|
self._next_line_id = max((line.id for line in self.lines), default=-1) + 1
|
||||||
|
self._next_block_id = max(
|
||||||
|
(block.id for line in self.lines for block in line.blocks),
|
||||||
|
default=-1,
|
||||||
|
) + 1
|
||||||
|
|
||||||
def _rebuild_lines_from_points(self) -> None:
|
def _rebuild_lines_from_points(self) -> None:
|
||||||
for line_id in sorted({point.line_id for point in self.points if point.line_id is not None}):
|
for line_id in sorted({point.line_id for point in self.points if point.line_id is not None}):
|
||||||
line = LedLine(int(line_id), f"Line {line_id}", default_color(int(line_id)))
|
line = LedLine(int(line_id), f"Line {line_id}", default_color(int(line_id)))
|
||||||
@ -445,7 +530,7 @@ class MappingDocument:
|
|||||||
if point.line_id == line_id
|
if point.line_id == line_id
|
||||||
]
|
]
|
||||||
if ids:
|
if ids:
|
||||||
line.blocks.append(LedBlock(self._next_block_id, "Block 1", ids))
|
line.blocks.append(LedBlock(self._next_block_id, f"Блок {self._next_block_id}", ids))
|
||||||
self._next_block_id += 1
|
self._next_block_id += 1
|
||||||
for block_id in block_ids:
|
for block_id in block_ids:
|
||||||
ids = [
|
ids = [
|
||||||
@ -513,8 +598,10 @@ class MappingDocument:
|
|||||||
payload["unfolding"] = self.describe_unfolding().to_payload()
|
payload["unfolding"] = self.describe_unfolding().to_payload()
|
||||||
payload["coordinate_unit"] = self._coordinate_unit_for_save()
|
payload["coordinate_unit"] = self._coordinate_unit_for_save()
|
||||||
payload["grid_step_mm"] = self.grid_step_mm
|
payload["grid_step_mm"] = self.grid_step_mm
|
||||||
|
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]:
|
||||||
@ -558,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:
|
||||||
@ -655,7 +768,7 @@ class MappingDocument:
|
|||||||
point_id for point_id in point_ids if 0 <= point_id < len(self.points)
|
point_id for point_id in point_ids if 0 <= point_id < len(self.points)
|
||||||
])
|
])
|
||||||
self._detach_points_from_blocks(clean_point_ids)
|
self._detach_points_from_blocks(clean_point_ids)
|
||||||
block = LedBlock(len(line.blocks) + 1, f"Блок {len(line.blocks) + 1}", clean_point_ids)
|
block = LedBlock(len(line.blocks), f"Блок {len(line.blocks)}", clean_point_ids)
|
||||||
insert_index = len(line.blocks)
|
insert_index = len(line.blocks)
|
||||||
if after_block_id is not None:
|
if after_block_id is not None:
|
||||||
for index, existing_block in enumerate(line.blocks):
|
for index, existing_block in enumerate(line.blocks):
|
||||||
@ -799,14 +912,14 @@ class MappingDocument:
|
|||||||
assigned = sum(1 for point in self.points if point.line_id is not None or point.block_id is not None)
|
assigned = sum(1 for point in self.points if point.line_id is not None or point.block_id is not None)
|
||||||
self.lines.clear()
|
self.lines.clear()
|
||||||
self._clear_points([point.uid for point in self.points])
|
self._clear_points([point.uid for point in self.points])
|
||||||
self._next_line_id = 1
|
self._next_line_id = 0
|
||||||
self._next_block_id = 1
|
self._next_block_id = 0
|
||||||
return assigned
|
return assigned
|
||||||
|
|
||||||
def renumber_lines(self) -> None:
|
def renumber_lines(self) -> None:
|
||||||
self.order_lines_clockwise()
|
self.order_lines_clockwise()
|
||||||
id_map: dict[int, int] = {}
|
id_map: dict[int, int] = {}
|
||||||
for new_id, line in enumerate(self.lines, start=1):
|
for new_id, line in enumerate(self.lines):
|
||||||
old_id = line.id
|
old_id = line.id
|
||||||
id_map[old_id] = new_id
|
id_map[old_id] = new_id
|
||||||
line.id = new_id
|
line.id = new_id
|
||||||
@ -818,7 +931,7 @@ class MappingDocument:
|
|||||||
if point.line_id in id_map:
|
if point.line_id in id_map:
|
||||||
point.line_id = id_map[point.line_id]
|
point.line_id = id_map[point.line_id]
|
||||||
|
|
||||||
self._next_line_id = len(self.lines) + 1
|
self._next_line_id = len(self.lines)
|
||||||
|
|
||||||
def _validate_line_limit(self) -> None:
|
def _validate_line_limit(self) -> None:
|
||||||
line_limit = self._line_limit()
|
line_limit = self._line_limit()
|
||||||
@ -985,7 +1098,7 @@ class MappingDocument:
|
|||||||
start_id = start_ids[0] if start_ids else point_ids[0]
|
start_id = start_ids[0] if start_ids else point_ids[0]
|
||||||
groups = self.route_groups_from_points(point_ids, start_id)
|
groups = self.route_groups_from_points(point_ids, start_id)
|
||||||
line.blocks.clear()
|
line.blocks.clear()
|
||||||
for block_number, group in enumerate(groups, start=1):
|
for block_number, group in enumerate(groups):
|
||||||
line.blocks.append(LedBlock(block_number, f"Блок {block_number}", group))
|
line.blocks.append(LedBlock(block_number, f"Блок {block_number}", group))
|
||||||
self.reindex_all()
|
self.reindex_all()
|
||||||
return len(groups)
|
return len(groups)
|
||||||
@ -997,7 +1110,7 @@ class MappingDocument:
|
|||||||
self._detach_points_from_blocks([point_id for group in groups for point_id in group])
|
self._detach_points_from_blocks([point_id for group in groups for point_id in group])
|
||||||
line = self.get_line(line_id)
|
line = self.get_line(line_id)
|
||||||
for group_ids in groups:
|
for group_ids in groups:
|
||||||
block_number = len(line.blocks) + 1
|
block_number = len(line.blocks)
|
||||||
block = LedBlock(block_number, f"Блок {block_number}", unique_in_order(group_ids))
|
block = LedBlock(block_number, f"Блок {block_number}", unique_in_order(group_ids))
|
||||||
line.blocks.append(block)
|
line.blocks.append(block)
|
||||||
self.order_blocks_clockwise(line_id, reorder_leds=False)
|
self.order_blocks_clockwise(line_id, reorder_leds=False)
|
||||||
@ -1107,7 +1220,7 @@ class MappingDocument:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def renumber_blocks(line: LedLine) -> None:
|
def renumber_blocks(line: LedLine) -> None:
|
||||||
for block_number, block in enumerate(line.blocks, start=1):
|
for block_number, block in enumerate(line.blocks):
|
||||||
block.id = block_number
|
block.id = block_number
|
||||||
if not block.name or re.match(r"^(Блок|Block)\s+\d+$", block.name):
|
if not block.name or re.match(r"^(Блок|Block)\s+\d+$", block.name):
|
||||||
block.name = f"Блок {block_number}"
|
block.name = f"Блок {block_number}"
|
||||||
@ -1216,7 +1329,7 @@ def _correlation_sign(value: float) -> int:
|
|||||||
|
|
||||||
def default_color(line_id: int) -> str:
|
def default_color(line_id: int) -> str:
|
||||||
colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"]
|
colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"]
|
||||||
return colors[(line_id - 1) % len(colors)]
|
return colors[line_id % len(colors)]
|
||||||
|
|
||||||
|
|
||||||
def distance(a: LedPoint, b: LedPoint) -> float:
|
def distance(a: LedPoint, b: LedPoint) -> float:
|
||||||
|
|||||||
@ -205,7 +205,7 @@ def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]:
|
|||||||
generated_id=index,
|
generated_id=index,
|
||||||
source_id=point.source_id,
|
source_id=point.source_id,
|
||||||
input_index=point.uid,
|
input_index=point.uid,
|
||||||
strip=(point.line_id - 1) if point.line_id is not None else None,
|
strip=point.line_id,
|
||||||
led_id=point.line_index,
|
led_id=point.line_index,
|
||||||
x_mm=point.x * scale,
|
x_mm=point.x * scale,
|
||||||
y_mm=point.y * scale,
|
y_mm=point.y * scale,
|
||||||
@ -586,6 +586,8 @@ def write_readme(path: Path) -> None:
|
|||||||
"- `validation_report.txt` — результаты проверки маршрутов и ячеек.",
|
"- `validation_report.txt` — результаты проверки маршрутов и ячеек.",
|
||||||
"- `README.md` — описание набора файлов.",
|
"- `README.md` — описание набора файлов.",
|
||||||
"",
|
"",
|
||||||
|
"Физический адрес ленты в `vm_map.h`: `strip = line_id`; нумерация начинается с `0`.",
|
||||||
|
"",
|
||||||
"Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся.",
|
"Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся.",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|||||||
733
qa_smoke.py
733
qa_smoke.py
@ -4,6 +4,7 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import importlib
|
import importlib
|
||||||
|
import io
|
||||||
import gc
|
import gc
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -13,6 +14,7 @@ import time
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
if __package__ is None or __package__ == "":
|
if __package__ is None or __package__ == "":
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
@ -29,6 +31,10 @@ 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
|
||||||
|
import app.settings as settings_module
|
||||||
|
import app.update_service as updater
|
||||||
from app.unfolding import MAX_LINES
|
from app.unfolding import MAX_LINES
|
||||||
try:
|
try:
|
||||||
import app.canvas as canvas_module
|
import app.canvas as canvas_module
|
||||||
@ -40,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:
|
||||||
@ -67,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
|
||||||
@ -92,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,
|
||||||
@ -99,6 +115,7 @@ def main() -> None:
|
|||||||
test_utf16_le_json_without_bom_is_converted_to_utf8_on_load,
|
test_utf16_le_json_without_bom_is_converted_to_utf8_on_load,
|
||||||
test_line_limit_rejects_fifth_line,
|
test_line_limit_rejects_fifth_line,
|
||||||
test_line_indexes_are_scoped_per_line,
|
test_line_indexes_are_scoped_per_line,
|
||||||
|
test_legacy_index_base_migration_roundtrip,
|
||||||
test_remove_selected_leds_blocks_and_lines,
|
test_remove_selected_leds_blocks_and_lines,
|
||||||
test_remove_blocks_across_lines_is_atomic,
|
test_remove_blocks_across_lines_is_atomic,
|
||||||
test_remove_block_preserves_line_numbers,
|
test_remove_block_preserves_line_numbers,
|
||||||
@ -139,6 +156,9 @@ def main() -> None:
|
|||||||
test_saved_coordinate_unit_is_reused,
|
test_saved_coordinate_unit_is_reused,
|
||||||
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_download_redirects_permissions_and_cancellation,
|
||||||
|
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,
|
||||||
test_standalone_panel_mapping_generation_without_legacy_ino,
|
test_standalone_panel_mapping_generation_without_legacy_ino,
|
||||||
@ -157,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,
|
||||||
@ -168,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,
|
||||||
@ -186,6 +210,7 @@ def main() -> None:
|
|||||||
test_canvas_rebuild_drops_deleted_selection_items,
|
test_canvas_rebuild_drops_deleted_selection_items,
|
||||||
test_canvas_refresh_does_not_accumulate_connection_items,
|
test_canvas_refresh_does_not_accumulate_connection_items,
|
||||||
test_main_window_has_classic_menu_bar,
|
test_main_window_has_classic_menu_bar,
|
||||||
|
test_update_actions_are_available_in_help_menu,
|
||||||
test_classic_menu_contains_expected_actions,
|
test_classic_menu_contains_expected_actions,
|
||||||
test_side_panel_hides_removed_tools,
|
test_side_panel_hides_removed_tools,
|
||||||
test_undo_redo_roundtrip_for_line_creation,
|
test_undo_redo_roundtrip_for_line_creation,
|
||||||
@ -194,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,
|
||||||
@ -256,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()
|
||||||
@ -390,6 +417,42 @@ def test_line_indexes_are_scoped_per_line() -> None:
|
|||||||
assert sorted(indexes_by_line.values()) == [[0, 1], [0, 1, 2]]
|
assert sorted(indexes_by_line.values()) == [[0, 1], [0, 1, 2]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_index_base_migration_roundtrip() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
source = Path(temp_dir) / "legacy.json"
|
||||||
|
saved = Path(temp_dir) / "migrated.json"
|
||||||
|
points = fixture_points()
|
||||||
|
for index in (0, 1):
|
||||||
|
points[index].update(line_id=1, block_id=1, line_index=index, block_index=index)
|
||||||
|
for index in (4, 5):
|
||||||
|
points[index].update(line_id=2, block_id=1, line_index=index - 4, block_index=index - 4)
|
||||||
|
source.write_text(json.dumps({
|
||||||
|
"circles": points,
|
||||||
|
"unknown_field": {"retained": True},
|
||||||
|
"lines": [
|
||||||
|
{"id": 1, "name": "Линия 1", "blocks": [{"id": 1, "name": "Блок 1", "point_ids": [0, 1]}]},
|
||||||
|
{"id": 2, "name": "Линия 2", "color": "#123456", "blocks": [{"id": 1, "name": "Блок 1", "point_ids": [4, 5]}]},
|
||||||
|
],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
doc = MappingDocument()
|
||||||
|
doc.load(source)
|
||||||
|
assert [line.id for line in doc.lines] == [0, 1]
|
||||||
|
assert [line.name for line in doc.lines] == ["Линия 0", "Линия 1"]
|
||||||
|
assert doc.lines[1].color == "#123456"
|
||||||
|
assert [line.blocks[0].id for line in doc.lines] == [0, 0]
|
||||||
|
assert [doc.points[index].line_id for index in (0, 1, 4, 5)] == [0, 0, 1, 1]
|
||||||
|
assert [doc.points[index].block_id for index in (0, 1, 4, 5)] == [0, 0, 0, 0]
|
||||||
|
generated = generate_panel_mapping_from_document(doc, Path(temp_dir) / "generated")
|
||||||
|
ordered = json.loads(generated.generated_files["ordered_json"].read_text(encoding="utf-8"))
|
||||||
|
assert sorted({point["strip"] for point in ordered["circles"] if point["strip"] is not None}) == [0, 1]
|
||||||
|
doc.save(saved)
|
||||||
|
assert json.loads(saved.read_text(encoding="utf-8"))["index_base"] == 0
|
||||||
|
again = MappingDocument()
|
||||||
|
again.load(saved)
|
||||||
|
assert again.to_payload() == doc.to_payload()
|
||||||
|
assert again.extra_payload["unknown_field"] == {"retained": True}
|
||||||
|
|
||||||
|
|
||||||
def test_remove_selected_leds_blocks_and_lines() -> None:
|
def test_remove_selected_leds_blocks_and_lines() -> None:
|
||||||
with temp_document_path() as path:
|
with temp_document_path() as path:
|
||||||
doc = MappingDocument()
|
doc = MappingDocument()
|
||||||
@ -467,8 +530,8 @@ def test_remove_block_preserves_line_numbers() -> None:
|
|||||||
removed = doc.remove_block(second_line.id, second_block.id)
|
removed = doc.remove_block(second_line.id, second_block.id)
|
||||||
|
|
||||||
assert removed == 2
|
assert removed == 2
|
||||||
assert [line.id for line in doc.lines] == [1, 2]
|
assert [line.id for line in doc.lines] == [0, 1]
|
||||||
assert [line.name for line in doc.lines] == ["Линия 1", "Линия 2"]
|
assert [line.name for line in doc.lines] == ["Линия 0", "Линия 1"]
|
||||||
assert doc.get_line(second_line.id).blocks == []
|
assert doc.get_line(second_line.id).blocks == []
|
||||||
|
|
||||||
|
|
||||||
@ -581,8 +644,8 @@ def test_line_numbers_do_not_renumber_when_points_are_cleared() -> None:
|
|||||||
|
|
||||||
doc.clear_point_assignments([0, 1])
|
doc.clear_point_assignments([0, 1])
|
||||||
|
|
||||||
assert [line.id for line in doc.lines] == [1, 2]
|
assert [line.id for line in doc.lines] == [0, 1]
|
||||||
assert [line.name for line in doc.lines] == ["Линия 1", "Линия 2"]
|
assert [line.name for line in doc.lines] == ["Линия 0", "Линия 1"]
|
||||||
|
|
||||||
|
|
||||||
def test_auto_blocks_reindex_all_affected_lines() -> None:
|
def test_auto_blocks_reindex_all_affected_lines() -> None:
|
||||||
@ -615,8 +678,8 @@ def test_legacy_rebuild_groups_by_line_and_block() -> None:
|
|||||||
doc.load(path)
|
doc.load(path)
|
||||||
assert len(doc.lines) == 2
|
assert len(doc.lines) == 2
|
||||||
assert [len(line.blocks) for line in doc.lines] == [1, 1]
|
assert [len(line.blocks) for line in doc.lines] == [1, 1]
|
||||||
assert sorted(doc.points[point_id].line_id for point_id in doc.lines[0].blocks[0].point_ids) == [1, 1]
|
assert sorted(doc.points[point_id].line_id for point_id in doc.lines[0].blocks[0].point_ids) == [0, 0]
|
||||||
assert sorted(doc.points[point_id].line_id for point_id in doc.lines[1].blocks[0].point_ids) == [2, 2]
|
assert sorted(doc.points[point_id].line_id for point_id in doc.lines[1].blocks[0].point_ids) == [1, 1]
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_line_without_block_id_is_preserved() -> None:
|
def test_legacy_line_without_block_id_is_preserved() -> None:
|
||||||
@ -815,8 +878,8 @@ def test_remove_points_reindexes_uids_and_blocks() -> None:
|
|||||||
assert [point.source_id for point in doc.points[:4]] == [0, 2, 3, 5]
|
assert [point.source_id for point in doc.points[:4]] == [0, 2, 3, 5]
|
||||||
assert first.point_ids == [0, 1]
|
assert first.point_ids == [0, 1]
|
||||||
assert second.point_ids == [2, 3]
|
assert second.point_ids == [2, 3]
|
||||||
assert [doc.points[point_id].block_id for point_id in first.point_ids] == [1, 1]
|
assert [doc.points[point_id].block_id for point_id in first.point_ids] == [0, 0]
|
||||||
assert [doc.points[point_id].block_id for point_id in second.point_ids] == [2, 2]
|
assert [doc.points[point_id].block_id for point_id in second.point_ids] == [1, 1]
|
||||||
|
|
||||||
|
|
||||||
def test_loaded_duplicate_memberships_are_deduplicated() -> None:
|
def test_loaded_duplicate_memberships_are_deduplicated() -> None:
|
||||||
@ -870,21 +933,21 @@ def test_block_numbers_renumber_after_block_changes() -> None:
|
|||||||
first = doc.add_block(line.id, [])
|
first = doc.add_block(line.id, [])
|
||||||
second = doc.add_block(line.id, [])
|
second = doc.add_block(line.id, [])
|
||||||
third = doc.add_block(line.id, [])
|
third = doc.add_block(line.id, [])
|
||||||
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"]
|
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 0", "Блок 1", "Блок 2"]
|
||||||
|
|
||||||
doc.reindex_all()
|
doc.reindex_all()
|
||||||
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"]
|
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 0", "Блок 1", "Блок 2"]
|
||||||
|
|
||||||
doc.remove_block(line.id, second.id)
|
doc.remove_block(line.id, second.id)
|
||||||
assert [(block.id, block.name) for block in doc.get_line(line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")]
|
assert [(block.id, block.name) for block in doc.get_line(line.id).blocks] == [(0, "Блок 0"), (1, "Блок 1")]
|
||||||
assert doc.points[0].block_id is None
|
assert doc.points[0].block_id is None
|
||||||
assert third.id == 2
|
assert third.id == 1
|
||||||
|
|
||||||
fourth = doc.add_block(line.id, [])
|
fourth = doc.add_block(line.id, [])
|
||||||
|
|
||||||
assert fourth.id == 3
|
assert fourth.id == 2
|
||||||
assert fourth.name == "Блок 3"
|
assert fourth.name == "Блок 2"
|
||||||
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"]
|
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 0", "Блок 1", "Блок 2"]
|
||||||
|
|
||||||
|
|
||||||
def test_block_numbers_are_local_to_each_line() -> None:
|
def test_block_numbers_are_local_to_each_line() -> None:
|
||||||
@ -898,14 +961,14 @@ def test_block_numbers_are_local_to_each_line() -> None:
|
|||||||
third = doc.add_block(first_line.id, [])
|
third = doc.add_block(first_line.id, [])
|
||||||
other_line_block = doc.add_block(second_line.id, [0])
|
other_line_block = doc.add_block(second_line.id, [0])
|
||||||
|
|
||||||
assert [block.id for block in doc.get_line(first_line.id).blocks] == [1, 2, 3]
|
assert [block.id for block in doc.get_line(first_line.id).blocks] == [0, 1, 2]
|
||||||
assert other_line_block.id == 1
|
assert other_line_block.id == 0
|
||||||
doc.remove_block(first_line.id, second.id)
|
doc.remove_block(first_line.id, second.id)
|
||||||
assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")]
|
assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(0, "Блок 0"), (1, "Блок 1")]
|
||||||
|
|
||||||
doc.remove_line(second_line.id)
|
doc.remove_line(second_line.id)
|
||||||
assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")]
|
assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(0, "Блок 0"), (1, "Блок 1")]
|
||||||
assert [first.name, third.name] == ["Блок 1", "Блок 2"]
|
assert [first.name, third.name] == ["Блок 0", "Блок 1"]
|
||||||
|
|
||||||
|
|
||||||
def test_reindex_preserves_block_point_order() -> None:
|
def test_reindex_preserves_block_point_order() -> None:
|
||||||
@ -968,15 +1031,15 @@ def test_add_block_can_insert_after_selected_block_and_renumber() -> None:
|
|||||||
inserted = doc.add_block(line.id, [2, 3], after_block_id=first.id)
|
inserted = doc.add_block(line.id, [2, 3], after_block_id=first.id)
|
||||||
|
|
||||||
assert [(block.id, block.name, block.point_ids) for block in line.blocks] == [
|
assert [(block.id, block.name, block.point_ids) for block in line.blocks] == [
|
||||||
(1, "Блок 1", [0, 1]),
|
(0, "Блок 0", [0, 1]),
|
||||||
(2, "Блок 2", [2, 3]),
|
(1, "Блок 1", [2, 3]),
|
||||||
(3, "Блок 3", [4, 5]),
|
(2, "Блок 2", [4, 5]),
|
||||||
(4, "Блок 4", [8, 9]),
|
(3, "Блок 3", [8, 9]),
|
||||||
]
|
]
|
||||||
assert inserted.id == 2
|
assert inserted.id == 1
|
||||||
assert second.id == 3
|
assert second.id == 2
|
||||||
assert third.id == 4
|
assert third.id == 3
|
||||||
assert [doc.points[point_id].block_id for point_id in [0, 2, 4, 8]] == [1, 2, 3, 4]
|
assert [doc.points[point_id].block_id for point_id in [0, 2, 4, 8]] == [0, 1, 2, 3]
|
||||||
assert [doc.points[point_id].line_index for point_id in [0, 1, 2, 3, 4, 5, 8, 9]] == list(range(8))
|
assert [doc.points[point_id].line_index for point_id in [0, 1, 2, 3, 4, 5, 8, 9]] == list(range(8))
|
||||||
|
|
||||||
|
|
||||||
@ -1244,6 +1307,175 @@ def test_panel_mapping_generation_from_document() -> None:
|
|||||||
assert ordered_payload["circles"][0]["strip"] == 0
|
assert ordered_payload["circles"][0]["strip"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_release_validation_and_download() -> 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}"
|
||||||
|
executable = bytearray(256)
|
||||||
|
executable[:2] = b"MZ"
|
||||||
|
executable[0x3C:0x40] = (128).to_bytes(4, "little")
|
||||||
|
executable[128:134] = b"PE\x00\x00\x64\x86"
|
||||||
|
release = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
"assets": [{"name": name, "size": len(executable), "browser_download_url": url}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def release_opener(_request, **_kwargs):
|
||||||
|
return FakeResponse(json.dumps(release).encode("utf-8"), "https://git.robotail.pro/api/v1/repos/Robotail/LED-mapper/releases/latest")
|
||||||
|
|
||||||
|
assert check_for_update("v3.2.1", opener=release_opener) is None
|
||||||
|
info = check_for_update("3.2.0", opener=release_opener)
|
||||||
|
assert info is not None and info.asset_name == name and info.version == "3.2.1"
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
downloaded = download_update(info, Path(temp_dir), opener=lambda _request, **_kwargs: FakeResponse(bytes(executable), url))
|
||||||
|
assert downloaded.read_bytes() == bytes(executable)
|
||||||
|
downloaded.unlink()
|
||||||
|
try:
|
||||||
|
download_update(info, Path(temp_dir), opener=lambda _request, **_kwargs: FakeResponse(b"bad", url))
|
||||||
|
except UpdateError:
|
||||||
|
assert not list(Path(temp_dir).glob(".ledmapper-update-*"))
|
||||||
|
else:
|
||||||
|
raise AssertionError("A truncated update must be rejected and removed")
|
||||||
|
release["assets"][0]["browser_download_url"] = "https://untrusted.example/update.exe"
|
||||||
|
try:
|
||||||
|
check_for_update("3.2.0", opener=release_opener)
|
||||||
|
except UpdateError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("An update on another host must be rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_download_redirects_permissions_and_cancellation() -> None:
|
||||||
|
class FakeResponse(io.BytesIO):
|
||||||
|
def __init__(self, data: bytes, url: str, content_length: int | None = None) -> None:
|
||||||
|
super().__init__(data)
|
||||||
|
self.url = url
|
||||||
|
self.headers = {}
|
||||||
|
if content_length is not None:
|
||||||
|
self.headers["Content-Length"] = str(content_length)
|
||||||
|
|
||||||
|
def geturl(self) -> str:
|
||||||
|
return self.url
|
||||||
|
|
||||||
|
tag = "v3.2.1"
|
||||||
|
name = f"LEDMapper-{tag}.exe"
|
||||||
|
url = f"https://git.robotail.pro/Robotail/LED-mapper/releases/download/{tag}/{name}"
|
||||||
|
payload = bytearray(256)
|
||||||
|
payload[:2] = b"MZ"
|
||||||
|
payload[0x3C:0x40] = (128).to_bytes(4, "little")
|
||||||
|
payload[128:134] = b"PE\x00\x00\x64\x86"
|
||||||
|
info = updater.UpdateInfo(tag, "3.2.1", name, url, len(payload))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
directory = Path(temp_dir)
|
||||||
|
for redirect in (
|
||||||
|
"https://git.robotail.pro/user/login",
|
||||||
|
"https://git.robotail.pro/Robotail/LED-mapper/releases/download/unexpected/update.exe",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
download_update(
|
||||||
|
info,
|
||||||
|
directory,
|
||||||
|
opener=lambda _request, redirect=redirect, **_kwargs: FakeResponse(bytes(payload), redirect),
|
||||||
|
)
|
||||||
|
except UpdateError:
|
||||||
|
assert not list(directory.glob(".ledmapper-update-*"))
|
||||||
|
else:
|
||||||
|
raise AssertionError("A same-host redirect outside the expected asset path must be rejected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
download_update(
|
||||||
|
info,
|
||||||
|
directory,
|
||||||
|
opener=lambda _request, **_kwargs: FakeResponse(bytes(payload), url, len(payload) + 1),
|
||||||
|
)
|
||||||
|
except UpdateError:
|
||||||
|
assert not list(directory.glob(".ledmapper-update-*"))
|
||||||
|
else:
|
||||||
|
raise AssertionError("A conflicting Content-Length must be rejected")
|
||||||
|
|
||||||
|
with patch.object(updater.os, "access", return_value=False):
|
||||||
|
try:
|
||||||
|
download_update(info, directory, opener=lambda _request, **_kwargs: FakeResponse(bytes(payload), url))
|
||||||
|
except UpdateError as exc:
|
||||||
|
assert "Нет прав" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("A read-only application directory must be rejected before download")
|
||||||
|
|
||||||
|
try:
|
||||||
|
download_update(
|
||||||
|
info,
|
||||||
|
directory,
|
||||||
|
opener=lambda _request, **_kwargs: FakeResponse(bytes(payload), url),
|
||||||
|
cancelled=lambda: True,
|
||||||
|
)
|
||||||
|
except updater.UpdateCancelled:
|
||||||
|
assert not list(directory.glob(".ledmapper-update-*"))
|
||||||
|
else:
|
||||||
|
raise AssertionError("A cancelled download must remove its partial file")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_install_keeps_executable_path_and_bundled_version() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
directory = Path(temp_dir)
|
||||||
|
old_executable = directory / "LEDMapper-v3.2.0.exe"
|
||||||
|
staged = directory / ".ledmapper-update-test.download"
|
||||||
|
payload = bytearray(256)
|
||||||
|
payload[:2] = b"MZ"
|
||||||
|
payload[0x3C:0x40] = (128).to_bytes(4, "little")
|
||||||
|
payload[128:134] = b"PE\x00\x00\x64\x86"
|
||||||
|
old_executable.write_bytes(payload)
|
||||||
|
staged.write_bytes(payload)
|
||||||
|
(directory / "ledmapper-build-version.txt").write_text("v3.2.1", encoding="utf-8")
|
||||||
|
info = updater.UpdateInfo(
|
||||||
|
"v3.2.1", "3.2.1", "LEDMapper-v3.2.1.exe",
|
||||||
|
"https://git.robotail.pro/Robotail/LED-mapper/releases/download/v3.2.1/LEDMapper-v3.2.1.exe",
|
||||||
|
len(payload),
|
||||||
|
)
|
||||||
|
with patch.object(updater.sys, "frozen", True, create=True), \
|
||||||
|
patch.object(updater.sys, "_MEIPASS", temp_dir, create=True), \
|
||||||
|
patch.object(updater.sys, "executable", str(old_executable)), \
|
||||||
|
patch.object(updater.subprocess, "Popen") as popen:
|
||||||
|
assert updater.current_version() == "v3.2.1"
|
||||||
|
updater.launch_update(staged, info, process_id=123)
|
||||||
|
arguments = popen.call_args.args[0]
|
||||||
|
assert arguments[8] == arguments[9] == str(old_executable)
|
||||||
|
creation_flags = popen.call_args.kwargs["creationflags"]
|
||||||
|
assert creation_flags & getattr(updater.subprocess, "CREATE_NO_WINDOW", 0) == getattr(
|
||||||
|
updater.subprocess, "CREATE_NO_WINDOW", 0
|
||||||
|
)
|
||||||
|
assert creation_flags & getattr(updater.subprocess, "CREATE_NEW_PROCESS_GROUP", 0) == getattr(
|
||||||
|
updater.subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
||||||
|
)
|
||||||
|
helper = Path(arguments[5])
|
||||||
|
script = helper.read_text(encoding="utf-8-sig")
|
||||||
|
assert "Wait-Process" in script and "$moveDeadline" in script
|
||||||
|
assert "Move-Item -LiteralPath $Backup" in script
|
||||||
|
quoted_path = str(helper).replace("'", "''")
|
||||||
|
parser_command = (
|
||||||
|
"$errors = @(); "
|
||||||
|
f"[System.Management.Automation.Language.Parser]::ParseFile('{quoted_path}', [ref]$null, [ref]$errors) | Out-Null; "
|
||||||
|
"if ($errors.Count) { exit 1 }"
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", parser_command],
|
||||||
|
check=True, stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_ordered_json_meter_coordinates_reload_as_mm() -> None:
|
def test_ordered_json_meter_coordinates_reload_as_mm() -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
doc = MappingDocument()
|
doc = MappingDocument()
|
||||||
@ -1603,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
|
||||||
@ -1626,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()
|
||||||
|
|
||||||
|
|
||||||
@ -1888,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
|
||||||
@ -2016,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:
|
||||||
@ -2229,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
|
||||||
@ -2675,6 +3014,31 @@ def test_main_window_has_classic_menu_bar() -> None:
|
|||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_actions_are_available_in_help_menu() -> None:
|
||||||
|
if MainWindow is None or QApplication is None:
|
||||||
|
return
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
window = MainWindow()
|
||||||
|
app.processEvents()
|
||||||
|
top_actions = list(window.menuBar().actions())
|
||||||
|
help_action = next(action for action in top_actions if action.text() == "Справка")
|
||||||
|
help_menu = help_action.menu()
|
||||||
|
assert help_menu is not None
|
||||||
|
assert [action.text() for action in help_menu.actions()] == ["Проверить обновления", "Установить обновление"]
|
||||||
|
assert window.actions["check_updates"].isEnabled()
|
||||||
|
assert not window.actions["install_update"].isEnabled()
|
||||||
|
info = updater.UpdateInfo(
|
||||||
|
"v3.2.1", "3.2.1", "LEDMapper-v3.2.1.exe",
|
||||||
|
"https://git.robotail.pro/Robotail/LED-mapper/releases/download/v3.2.1/LEDMapper-v3.2.1.exe",
|
||||||
|
1024,
|
||||||
|
)
|
||||||
|
with patch.object(sys, "frozen", True, create=True):
|
||||||
|
window._on_update_checked(info)
|
||||||
|
assert window.actions["install_update"].isEnabled()
|
||||||
|
assert "v3.2.1" in window.actions["install_update"].text()
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
def test_classic_menu_contains_expected_actions() -> None:
|
def test_classic_menu_contains_expected_actions() -> None:
|
||||||
if MainWindow is None or QApplication is None:
|
if MainWindow is None or QApplication is None:
|
||||||
return
|
return
|
||||||
@ -2702,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()
|
||||||
@ -2906,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
|
||||||
@ -2922,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
|
||||||
@ -2946,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
|
||||||
@ -3543,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)
|
||||||
|
|
||||||
@ -3938,14 +4323,14 @@ def test_brush_creates_new_block_after_selected_tree_block() -> None:
|
|||||||
|
|
||||||
line_after = window.document.get_line(line.id)
|
line_after = window.document.get_line(line.id)
|
||||||
assert [(block.id, block.point_ids) for block in line_after.blocks] == [
|
assert [(block.id, block.point_ids) for block in line_after.blocks] == [
|
||||||
(1, [0, 1]),
|
(0, [0, 1]),
|
||||||
(2, [2, 3]),
|
(1, [2, 3]),
|
||||||
(3, [4, 5]),
|
(2, [4, 5]),
|
||||||
]
|
]
|
||||||
assert [window.document.points[point_id].block_id for point_id in [0, 2, 4]] == [1, 2, 3]
|
assert [window.document.points[point_id].block_id for point_id in [0, 2, 4]] == [0, 1, 2]
|
||||||
current = window.tree.currentItem()
|
current = window.tree.currentItem()
|
||||||
assert current is not None
|
assert current is not None
|
||||||
assert current.data(0, Qt.UserRole) == ("block", line.id, 2)
|
assert current.data(0, Qt.UserRole) == ("block", line.id, 1)
|
||||||
assert window.canvas.selected_point_ids() == [2, 3]
|
assert window.canvas.selected_point_ids() == [2, 3]
|
||||||
window._dirty = False
|
window._dirty = False
|
||||||
window.close()
|
window.close()
|
||||||
@ -4125,6 +4510,8 @@ def test_pyinstaller_spec_build_ref_rules() -> None:
|
|||||||
(repo / "tracked.txt").write_text("clean\n", encoding="utf-8")
|
(repo / "tracked.txt").write_text("clean\n", encoding="utf-8")
|
||||||
subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True)
|
subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True)
|
||||||
subprocess.run(["git", "commit", "-m", "initial"], cwd=repo, check=True, stdout=subprocess.DEVNULL)
|
subprocess.run(["git", "commit", "-m", "initial"], cwd=repo, check=True, stdout=subprocess.DEVNULL)
|
||||||
|
subprocess.run(["git", "add", "runner.py"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "track runner"], cwd=repo, check=True, stdout=subprocess.DEVNULL)
|
||||||
commit = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=repo, text=True).strip()
|
commit = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=repo, text=True).strip()
|
||||||
|
|
||||||
clean_ref = subprocess.check_output([sys.executable, str(runner)], cwd=repo, text=True).strip()
|
clean_ref = subprocess.check_output([sys.executable, str(runner)], cwd=repo, text=True).strip()
|
||||||
@ -4139,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)
|
||||||
@ -4522,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()
|
||||||
|
|||||||
12
settings.py
12
settings.py
@ -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"}:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
348
update_service.py
Normal file
348
update_service.py
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
"""Безопасная проверка и установка Windows-релизов LED Mapper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import BinaryIO, Callable
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .version import __version__
|
||||||
|
except ImportError:
|
||||||
|
from version import __version__
|
||||||
|
|
||||||
|
RELEASE_API_URL = "https://git.robotail.pro/api/v1/repos/Robotail/LED-mapper/releases/latest"
|
||||||
|
RELEASE_PAGE_URL = "https://git.robotail.pro/Robotail/LED-mapper/releases"
|
||||||
|
RELEASE_HOST = "git.robotail.pro"
|
||||||
|
READY_ENV = "LEDMAPPER_UPDATE_READY_FILE"
|
||||||
|
MAX_METADATA_BYTES = 1024 * 1024
|
||||||
|
MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024
|
||||||
|
CHUNK_BYTES = 1024 * 1024
|
||||||
|
VERSION_RE = re.compile(r"v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\Z")
|
||||||
|
EXECUTABLE_RE = re.compile(r"LEDMapper-(v\d+\.\d+\.\d+)\.exe\Z", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateError(RuntimeError):
|
||||||
|
"""Проверка, загрузка или установка обновления не выполнена."""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCancelled(UpdateError):
|
||||||
|
"""Загрузка отменена пользователем."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UpdateInfo:
|
||||||
|
tag: str
|
||||||
|
version: str
|
||||||
|
asset_name: str
|
||||||
|
download_url: str
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(value: str) -> tuple[int, int, int]:
|
||||||
|
match = VERSION_RE.fullmatch(value.strip())
|
||||||
|
if match is None:
|
||||||
|
raise UpdateError("Версия релиза должна иметь формат vX.Y.Z")
|
||||||
|
return tuple(int(part) for part in match.groups())
|
||||||
|
|
||||||
|
|
||||||
|
def current_version() -> str:
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
bundle_dir = getattr(sys, "_MEIPASS", None)
|
||||||
|
if bundle_dir is not None:
|
||||||
|
try:
|
||||||
|
bundled_version = (Path(bundle_dir) / "ledmapper-build-version.txt").read_text(encoding="utf-8").strip()
|
||||||
|
if VERSION_RE.fullmatch(bundled_version):
|
||||||
|
return bundled_version
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
match = EXECUTABLE_RE.fullmatch(Path(sys.executable).name)
|
||||||
|
if match is not None:
|
||||||
|
return match.group(1)
|
||||||
|
return __version__
|
||||||
|
|
||||||
|
|
||||||
|
def _trusted_url(value: str) -> bool:
|
||||||
|
parsed = urlparse(value)
|
||||||
|
return (
|
||||||
|
parsed.scheme == "https"
|
||||||
|
and parsed.hostname == RELEASE_HOST
|
||||||
|
and parsed.port in (None, 443)
|
||||||
|
and not parsed.username
|
||||||
|
and not parsed.password
|
||||||
|
and not parsed.fragment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_update(
|
||||||
|
installed_version: str,
|
||||||
|
*,
|
||||||
|
opener: Callable[..., BinaryIO] | None = None,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
) -> UpdateInfo | None:
|
||||||
|
"""Возвращает только новый релиз с ровно одним подходящим Windows EXE."""
|
||||||
|
installed = parse_version(installed_version)
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
raise UpdateError("Автообновление доступно только в Windows")
|
||||||
|
request = Request(
|
||||||
|
RELEASE_API_URL,
|
||||||
|
headers={"Accept": "application/json", "User-Agent": "LEDMapper-Updater"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with (opener or urlopen)(request, timeout=timeout) as response:
|
||||||
|
if not _trusted_url(response.geturl()):
|
||||||
|
raise UpdateError("Сервер релизов перенаправил запрос на другой сайт")
|
||||||
|
payload = response.read(MAX_METADATA_BYTES + 1)
|
||||||
|
if len(payload) > MAX_METADATA_BYTES:
|
||||||
|
raise UpdateError("Ответ сервера релизов слишком велик")
|
||||||
|
release = json.loads(payload)
|
||||||
|
except UpdateError:
|
||||||
|
raise
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
raise UpdateError(f"Не удалось проверить обновления: {exc}") from exc
|
||||||
|
if not isinstance(release, dict) or release.get("draft") or release.get("prerelease"):
|
||||||
|
raise UpdateError("Сервер не вернул опубликованный стабильный релиз")
|
||||||
|
tag = release.get("tag_name")
|
||||||
|
if not isinstance(tag, str) or not tag.startswith("v"):
|
||||||
|
raise UpdateError("У релиза отсутствует корректный тег")
|
||||||
|
remote = parse_version(tag)
|
||||||
|
if remote <= installed:
|
||||||
|
return None
|
||||||
|
expected_name = f"LEDMapper-{tag}.exe"
|
||||||
|
assets = release.get("assets")
|
||||||
|
if not isinstance(assets, list):
|
||||||
|
raise UpdateError("У релиза отсутствует список артефактов")
|
||||||
|
matching = [asset for asset in assets if isinstance(asset, dict) and asset.get("name") == expected_name]
|
||||||
|
if len(matching) != 1:
|
||||||
|
raise UpdateError(f"Не найден Windows-артефакт {expected_name}")
|
||||||
|
asset = matching[0]
|
||||||
|
download_url = asset.get("browser_download_url")
|
||||||
|
expected_path = f"/Robotail/LED-mapper/releases/download/{tag}/{expected_name}"
|
||||||
|
if not isinstance(download_url, str) or not _trusted_url(download_url) or urlparse(download_url).path != expected_path:
|
||||||
|
raise UpdateError("Релиз содержит неожиданный адрес артефакта")
|
||||||
|
size = asset.get("size")
|
||||||
|
if isinstance(size, bool) or not isinstance(size, int) or not 0 < size <= MAX_DOWNLOAD_BYTES:
|
||||||
|
raise UpdateError("Релиз содержит недопустимый размер артефакта")
|
||||||
|
return UpdateInfo(tag, tag.removeprefix("v"), expected_name, download_url, size)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_executable(path: Path) -> None:
|
||||||
|
with path.open("rb") as source:
|
||||||
|
header = source.read(64)
|
||||||
|
if len(header) < 64 or header[:2] != b"MZ":
|
||||||
|
raise UpdateError("Скачанный файл не является Windows EXE")
|
||||||
|
pe_offset = int.from_bytes(header[0x3C:0x40], "little")
|
||||||
|
if not 64 <= pe_offset <= 1024 * 1024:
|
||||||
|
raise UpdateError("Некорректный заголовок Windows EXE")
|
||||||
|
source.seek(pe_offset)
|
||||||
|
if source.read(4) != b"PE\x00\x00":
|
||||||
|
raise UpdateError("Некорректный заголовок Windows EXE")
|
||||||
|
if source.read(2) != b"\x64\x86":
|
||||||
|
raise UpdateError("Обновление не предназначено для Windows x64")
|
||||||
|
|
||||||
|
|
||||||
|
def download_update(
|
||||||
|
info: UpdateInfo,
|
||||||
|
directory: Path,
|
||||||
|
*,
|
||||||
|
opener: Callable[..., BinaryIO] | None = None,
|
||||||
|
progress: Callable[[int], None] | None = None,
|
||||||
|
cancelled: Callable[[], bool] | None = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> Path:
|
||||||
|
"""Скачивает артефакт в отдельный временный файл и проверяет размер/PE."""
|
||||||
|
target_dir = directory.resolve()
|
||||||
|
if not target_dir.is_dir():
|
||||||
|
raise UpdateError("Каталог приложения не найден")
|
||||||
|
if not os.access(target_dir, os.W_OK):
|
||||||
|
raise UpdateError("Нет прав на запись в каталог приложения")
|
||||||
|
if not _trusted_url(info.download_url) or urlparse(info.download_url).path != (
|
||||||
|
f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
|
||||||
|
):
|
||||||
|
raise UpdateError("Недопустимый адрес загрузки обновления")
|
||||||
|
expected_path = f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
|
||||||
|
path: Path | None = None
|
||||||
|
received = 0
|
||||||
|
request = Request(info.download_url, headers={"User-Agent": "LEDMapper-Updater"})
|
||||||
|
try:
|
||||||
|
with (opener or urlopen)(request, timeout=timeout) as response:
|
||||||
|
if not _trusted_url(response.geturl()):
|
||||||
|
raise UpdateError("Загрузка перенаправлена на другой сайт")
|
||||||
|
final_path = urlparse(response.geturl()).path
|
||||||
|
if final_path == "/user/login":
|
||||||
|
raise UpdateError("Release-артефакты недоступны без авторизации")
|
||||||
|
if final_path != expected_path:
|
||||||
|
raise UpdateError("Загрузка перенаправлена на неожиданный адрес")
|
||||||
|
content_length = getattr(response, "headers", {}).get("Content-Length")
|
||||||
|
if content_length is not None:
|
||||||
|
try:
|
||||||
|
declared_size = int(content_length)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise UpdateError("Сервер вернул некорректный размер обновления") from exc
|
||||||
|
if declared_size != info.size:
|
||||||
|
raise UpdateError("Размер обновления не совпадает с данными релиза")
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="wb", dir=target_dir, prefix=".ledmapper-update-", suffix=".download", delete=False
|
||||||
|
) as output:
|
||||||
|
path = Path(output.name)
|
||||||
|
while True:
|
||||||
|
if cancelled is not None and cancelled():
|
||||||
|
raise UpdateCancelled("Загрузка обновления отменена")
|
||||||
|
chunk = response.read(CHUNK_BYTES)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
received += len(chunk)
|
||||||
|
if received > MAX_DOWNLOAD_BYTES or received > info.size:
|
||||||
|
raise UpdateError("Артефакт превышает объявленный размер")
|
||||||
|
output.write(chunk)
|
||||||
|
if progress is not None:
|
||||||
|
progress(min(received * 100 // info.size, 100))
|
||||||
|
output.flush()
|
||||||
|
os.fsync(output.fileno())
|
||||||
|
if received != info.size:
|
||||||
|
raise UpdateError("Артефакт скачан не полностью")
|
||||||
|
_check_executable(path)
|
||||||
|
if progress is not None:
|
||||||
|
progress(100)
|
||||||
|
return path
|
||||||
|
except (UpdateError, OSError) as exc:
|
||||||
|
if path is not None:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
if isinstance(exc, UpdateError):
|
||||||
|
raise
|
||||||
|
raise UpdateError(f"Не удалось скачать обновление: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def current_executable() -> Path:
|
||||||
|
if not getattr(sys, "frozen", False):
|
||||||
|
raise UpdateError("Установка обновления доступна только в собранном EXE")
|
||||||
|
return Path(sys.executable).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def signal_update_ready() -> None:
|
||||||
|
marker = os.environ.pop(READY_ENV, None)
|
||||||
|
if marker is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
path = Path(marker).resolve()
|
||||||
|
if path.parent != current_executable().parent or not path.name.startswith(".ledmapper-ready-"):
|
||||||
|
return
|
||||||
|
path.write_text(str(os.getpid()), encoding="ascii")
|
||||||
|
except (OSError, UpdateError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _write_windows_updater(path: Path) -> None:
|
||||||
|
script = r'''param(
|
||||||
|
[int]$ApplicationProcessId,
|
||||||
|
[string]$Source,
|
||||||
|
[string]$OldTarget,
|
||||||
|
[string]$NewTarget,
|
||||||
|
[string]$Backup,
|
||||||
|
[string]$ReadyFile
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$applicationDirectory = Split-Path -Parent $OldTarget
|
||||||
|
$errorLog = Join-Path $applicationDirectory "update-error.log"
|
||||||
|
$restarted = $null
|
||||||
|
$oldMoved = $false
|
||||||
|
$newMoved = $false
|
||||||
|
try {
|
||||||
|
Wait-Process -Id $ApplicationProcessId -ErrorAction SilentlyContinue
|
||||||
|
# Родительский процесс PyInstaller может ещё удерживать EXE при очистке.
|
||||||
|
$moveDeadline = (Get-Date).AddSeconds(30)
|
||||||
|
while (-not $oldMoved) {
|
||||||
|
try {
|
||||||
|
Move-Item -LiteralPath $OldTarget -Destination $Backup -ErrorAction Stop
|
||||||
|
$oldMoved = $true
|
||||||
|
} catch {
|
||||||
|
if ((Get-Date) -ge $moveDeadline) { throw }
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $NewTarget) { throw "Целевой EXE уже существует" }
|
||||||
|
Move-Item -LiteralPath $Source -Destination $NewTarget -ErrorAction Stop
|
||||||
|
$newMoved = $true
|
||||||
|
$env:LEDMAPPER_UPDATE_READY_FILE = $ReadyFile
|
||||||
|
$env:PYINSTALLER_RESET_ENVIRONMENT = "1"
|
||||||
|
$restarted = Start-Process -FilePath $NewTarget -WorkingDirectory $applicationDirectory -PassThru
|
||||||
|
$deadline = (Get-Date).AddSeconds(60)
|
||||||
|
while (-not (Test-Path -LiteralPath $ReadyFile -PathType Leaf)) {
|
||||||
|
if ($restarted.HasExited) { throw "Новая версия завершилась до открытия окна" }
|
||||||
|
if ((Get-Date) -ge $deadline) { throw "Новая версия не подтвердила запуск" }
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
if ($restarted.HasExited) { throw "Новая версия завершилась сразу после запуска" }
|
||||||
|
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $Backup -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
|
||||||
|
} catch {
|
||||||
|
$failure = $_.ToString()
|
||||||
|
if ($restarted -and -not $restarted.HasExited) {
|
||||||
|
& taskkill.exe /PID $restarted.Id /T /F *> $null
|
||||||
|
}
|
||||||
|
if ($newMoved -and (Test-Path -LiteralPath $NewTarget)) {
|
||||||
|
Remove-Item -LiteralPath $NewTarget -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
if ($oldMoved -and (Test-Path -LiteralPath $Backup)) {
|
||||||
|
Move-Item -LiteralPath $Backup -Destination $OldTarget -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
|
||||||
|
$failure | Out-File -LiteralPath $errorLog -Encoding utf8
|
||||||
|
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
path.write_text(script, encoding="utf-8-sig", newline="\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None = None) -> None:
|
||||||
|
"""Запускает отдельный helper; замену EXE он делает после выхода приложения."""
|
||||||
|
old_target = current_executable()
|
||||||
|
source = downloaded.resolve()
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
raise UpdateError("Установка обновления доступна только в Windows")
|
||||||
|
if not os.access(old_target.parent, os.W_OK):
|
||||||
|
raise UpdateError("Нет прав на замену приложения")
|
||||||
|
if source.parent != old_target.parent or not source.is_file() or not source.name.startswith(".ledmapper-update-"):
|
||||||
|
raise UpdateError("Скачанный файл должен находиться рядом с приложением")
|
||||||
|
if not EXECUTABLE_RE.fullmatch(info.asset_name):
|
||||||
|
raise UpdateError("Недопустимое имя Windows-артефакта")
|
||||||
|
new_target = old_target
|
||||||
|
if source.stat().st_size != info.size:
|
||||||
|
raise UpdateError("Размер скачанного артефакта изменился")
|
||||||
|
_check_executable(source)
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
script_path = old_target.parent / f".ledmapper-updater-{token}.ps1"
|
||||||
|
backup_path = old_target.parent / f".ledmapper-backup-{token}.exe"
|
||||||
|
ready_path = old_target.parent / f".ledmapper-ready-{token}"
|
||||||
|
try:
|
||||||
|
_write_windows_updater(script_path)
|
||||||
|
creation_flags = (
|
||||||
|
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
)
|
||||||
|
subprocess.Popen(
|
||||||
|
[
|
||||||
|
"powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
|
||||||
|
str(script_path), str(process_id or os.getpid()), str(source), str(old_target),
|
||||||
|
str(new_target), str(backup_path), str(ready_path),
|
||||||
|
],
|
||||||
|
close_fds=True,
|
||||||
|
creationflags=creation_flags,
|
||||||
|
)
|
||||||
|
except (OSError, UpdateError) as exc:
|
||||||
|
script_path.unlink(missing_ok=True)
|
||||||
|
if isinstance(exc, UpdateError):
|
||||||
|
raise
|
||||||
|
raise UpdateError(f"Не удалось запустить установщик: {exc}") from exc
|
||||||
3
version.py
Normal file
3
version.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""Текущая версия приложения; обновляется при публикации релиза."""
|
||||||
|
|
||||||
|
__version__ = "3.5.0"
|
||||||
Loading…
x
Reference in New Issue
Block a user