feat: Release v3.5.0 with free-form text comments
- Добавлены свободные многострочные комментарии на схеме: создание через меню, перемещение мышью, редактирование и удаление. - Реализовано хранение комментариев в JSON с устойчивыми идентификаторами, координатами и сохранением неизвестных полей. - Все операции с комментариями включены в Undo/Redo; поддержаны схемы только с заметками и сохранение удаления последней заметки. - Исправлены границы общего вида, неограниченный масштаб схемы с одной заметкой и завершение перетаскивания при панорамировании пробелом. - Добавлены семь smoke-сценариев, проверки совместимости генератора и обновлено руководство пользователя. - Версия приложения обновлена до 3.5.0.
This commit is contained in:
parent
25ca2101a4
commit
30ac54f77f
13
README.md
13
README.md
@ -19,7 +19,8 @@ LED Mapper — настольное приложение на Python и PySide6
|
||||
- Предпросмотр зоны действия кисти и точек, которые будут добавлены.
|
||||
- Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации.
|
||||
- Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении.
|
||||
- Undo/redo для операций разметки.
|
||||
- Свободные многострочные текстовые комментарии на схеме.
|
||||
- Undo/redo для операций разметки и комментариев.
|
||||
- Удаление назначений через `Del` и контекстные меню.
|
||||
- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo.
|
||||
- Сворачивание боковой панели.
|
||||
@ -101,6 +102,16 @@ python -m venv .venv
|
||||
|
||||
Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование.
|
||||
|
||||
## Текстовые комментарии на схеме
|
||||
|
||||
Чтобы разместить заметку, нажмите правой кнопкой мыши в нужном месте схемы и выберите `Добавить комментарий…`. Команда `Инструменты -> Добавить комментарий…` создаёт заметку в центре видимой области. Введите многострочный текст и подтвердите ввод.
|
||||
|
||||
Комментарий можно свободно перетаскивать левой кнопкой мыши в любом режиме. Двойной щелчок открывает редактирование текста; в контекстном меню комментария доступны изменение и удаление. Размер текста остаётся читаемым при изменении масштаба. Команда `Показать всё` учитывает комментарии, в том числе за пределами LED-схемы.
|
||||
|
||||
Добавление, изменение, перемещение и удаление поддерживают Undo/Redo. Отмена диалога и пустой текст не изменяют схему. Комментарии сохраняются в JSON в списке `comments`: `id`, `text`, `x`, `y`; координаты используют единицы схемы. Старые JSON без этого списка открываются без комментариев. Можно сохранить схему, содержащую только комментарии.
|
||||
|
||||
Комментарии не привязаны к линиям, блокам или LED и не влияют на физические адреса в `VM_MAPPINGS`. Экспорт изображения маппинга через меню `Файл` по-прежнему показывает LED и легенду; текстовые комментарии сохраняются в JSON и отображаются в редакторе.
|
||||
|
||||
## Горячие клавиши
|
||||
|
||||
| Действие | Клавиши |
|
||||
|
||||
179
canvas.py
179
canvas.py
@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
|
||||
QGraphicsRectItem,
|
||||
QGraphicsScene,
|
||||
QGraphicsSimpleTextItem,
|
||||
QGraphicsTextItem,
|
||||
QGraphicsView,
|
||||
QMenu,
|
||||
QScrollBar,
|
||||
@ -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):
|
||||
selectionChanged = Signal(list)
|
||||
deleteSelectionRequested = Signal()
|
||||
@ -68,6 +106,10 @@ class LedCanvas(QGraphicsView):
|
||||
brushPointTouched = Signal(int)
|
||||
brushStrokeFinished = Signal()
|
||||
addLedRequested = Signal(float, float)
|
||||
addCommentRequested = Signal(float, float)
|
||||
editCommentRequested = Signal(str)
|
||||
deleteCommentRequested = Signal(str)
|
||||
commentMoveFinished = Signal(str, float, float)
|
||||
backgroundEditStarted = Signal()
|
||||
backgroundChanged = Signal()
|
||||
backgroundEditFinished = Signal(bool)
|
||||
@ -85,6 +127,8 @@ class LedCanvas(QGraphicsView):
|
||||
self.document: MappingDocument | None = None
|
||||
self.active_line_id: int | None = None
|
||||
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.connection_items: list[QGraphicsPathItem] = []
|
||||
self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {}
|
||||
@ -400,6 +444,8 @@ class LedCanvas(QGraphicsView):
|
||||
self.setUpdatesEnabled(False)
|
||||
try:
|
||||
self.items_by_id.clear()
|
||||
self.comment_items.clear()
|
||||
self._comment_drag = None
|
||||
self.grid_items.clear()
|
||||
self.connection_items.clear()
|
||||
self.line_index_label_items.clear()
|
||||
@ -422,8 +468,15 @@ class LedCanvas(QGraphicsView):
|
||||
self._raw_bounds = None
|
||||
self._content_scene_rect = None
|
||||
if self.document is None or not self.document.points:
|
||||
self._display_scale = 1.0
|
||||
self._refresh_background()
|
||||
self._refresh_comments()
|
||||
self._update_scene_rect()
|
||||
if reset_view:
|
||||
self.fit_to_content()
|
||||
else:
|
||||
self.setTransform(old_transform)
|
||||
self.centerOn(old_center)
|
||||
return
|
||||
|
||||
# Keep display scale completely stable to prevent grid and camera jumps
|
||||
@ -454,6 +507,7 @@ class LedCanvas(QGraphicsView):
|
||||
stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds
|
||||
self._update_led_content_rect()
|
||||
self._refresh_background()
|
||||
self._refresh_comments()
|
||||
self._update_scene_rect()
|
||||
self._refresh_grid()
|
||||
self.refresh_styles()
|
||||
@ -476,6 +530,8 @@ class LedCanvas(QGraphicsView):
|
||||
self.centerOn(old_center)
|
||||
|
||||
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:
|
||||
return
|
||||
if redraw_connections:
|
||||
@ -624,8 +680,47 @@ class LedCanvas(QGraphicsView):
|
||||
return None
|
||||
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:
|
||||
rect = self._content_scene_rect
|
||||
rect = self._rect_with_comments(self._content_scene_rect)
|
||||
background_rect = self._background_scene_rect()
|
||||
if rect is None:
|
||||
rect = background_rect
|
||||
@ -679,21 +774,26 @@ class LedCanvas(QGraphicsView):
|
||||
handle.setPos(point)
|
||||
|
||||
def fit_to_content(self) -> None:
|
||||
self._update_scene_rect()
|
||||
if self.scene() is None or self.sceneRect().isEmpty():
|
||||
if self.scene() is None:
|
||||
return
|
||||
|
||||
# Focus camera on actual document content bounds, ignoring expanded edit sceneRect bounds
|
||||
rect = self._content_scene_rect
|
||||
background_rect = self._background_scene_rect()
|
||||
if rect is None:
|
||||
rect = background_rect
|
||||
elif background_rect is not None:
|
||||
rect = rect.united(background_rect)
|
||||
if rect is None or rect.isEmpty():
|
||||
rect = self.sceneRect()
|
||||
|
||||
self.fitInView(rect, Qt.KeepAspectRatio)
|
||||
# Размер заметок в пикселях постоянен; их границы в сцене зависят от масштаба.
|
||||
for _ in range(8 if self.comment_items else 1):
|
||||
self._update_scene_rect()
|
||||
rect = self._rect_with_comments(self._content_scene_rect)
|
||||
background_rect = self._background_scene_rect()
|
||||
if rect is None:
|
||||
rect = background_rect
|
||||
elif background_rect is not None:
|
||||
rect = rect.united(background_rect)
|
||||
if rect is None or rect.isEmpty():
|
||||
rect = self.sceneRect()
|
||||
if rect.isEmpty():
|
||||
return
|
||||
self.fitInView(rect, Qt.KeepAspectRatio)
|
||||
if self.comment_items and self.transform().m11() > 1.0:
|
||||
self.resetTransform()
|
||||
self.centerOn(rect.center())
|
||||
break
|
||||
self._refresh_grid()
|
||||
self._position_line_index_labels()
|
||||
self._update_selection_bbox()
|
||||
@ -799,6 +899,16 @@ class LedCanvas(QGraphicsView):
|
||||
self._start_pan(event.pos())
|
||||
event.accept()
|
||||
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"
|
||||
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()):
|
||||
@ -855,6 +965,11 @@ class LedCanvas(QGraphicsView):
|
||||
self._move_scrollbar(self.verticalScrollBar(), delta.y())
|
||||
event.accept()
|
||||
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:
|
||||
self._move_background_edit(event.pos())
|
||||
event.accept()
|
||||
@ -905,6 +1020,17 @@ class LedCanvas(QGraphicsView):
|
||||
self._finish_pan()
|
||||
event.accept()
|
||||
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:
|
||||
self._finish_background_edit()
|
||||
event.accept()
|
||||
@ -1277,6 +1403,9 @@ class LedCanvas(QGraphicsView):
|
||||
)
|
||||
|
||||
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._last_pan_pos = pos
|
||||
self._apply_default_cursor()
|
||||
@ -1287,10 +1416,24 @@ class LedCanvas(QGraphicsView):
|
||||
self._apply_default_cursor()
|
||||
|
||||
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())
|
||||
point_id = item.point_id if item is not None else self._single_selected_point_id()
|
||||
|
||||
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.setEnabled(point_id is not None)
|
||||
delete_action = menu.addAction("Удалить фиксацию выбранных LED")
|
||||
@ -1338,9 +1481,11 @@ class LedCanvas(QGraphicsView):
|
||||
select_block_action = menu.addAction(f"Выделить блок '{block_name}'")
|
||||
|
||||
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())
|
||||
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)
|
||||
elif chosen == delete_action:
|
||||
self.deleteSelectionRequested.emit()
|
||||
|
||||
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. Работа ведётся поверх текущих файлов без отката.
|
||||
@ -269,6 +269,10 @@ class MainWindow(QMainWindow):
|
||||
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
|
||||
self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise)
|
||||
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.brushPointTouched.connect(self.add_brush_point_to_block)
|
||||
self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke)
|
||||
@ -374,6 +378,7 @@ class MainWindow(QMainWindow):
|
||||
self.actions[key] = action
|
||||
return action
|
||||
|
||||
add_action("add_comment", "Добавить комментарий…", self.add_comment_at_center)
|
||||
add_action("new", "Создать...", self.new_file, "file-plus", QKeySequence.New)
|
||||
add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open)
|
||||
add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save)
|
||||
@ -553,6 +558,7 @@ class MainWindow(QMainWindow):
|
||||
tools_menu = menu_bar.addMenu("Инструменты")
|
||||
tools_menu.addAction(self.actions["add_line"])
|
||||
tools_menu.addAction(self.actions["add_led"])
|
||||
tools_menu.addAction(self.actions["add_comment"])
|
||||
tools_menu.addAction(self.actions["generate_script"])
|
||||
tools_menu.addSeparator()
|
||||
tools_menu.addAction(self.actions["auto_blocks"])
|
||||
@ -1718,6 +1724,55 @@ class MainWindow(QMainWindow):
|
||||
self.status.setText(f"Создана {line.name}. {self._line_summary(line)}")
|
||||
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:
|
||||
self._push_undo()
|
||||
point = self.document.add_manual_point(x, y)
|
||||
@ -2838,8 +2893,8 @@ class MainWindow(QMainWindow):
|
||||
self.selection_label.setText(self._selection_summary(point_ids))
|
||||
|
||||
state = {
|
||||
"save": has_points and self._dirty,
|
||||
"save_as": has_points,
|
||||
"save": bool(has_points or self.document.comments or self.document.path is not None) and self._dirty,
|
||||
"save_as": bool(has_points or self.document.comments or self.document.path is not None),
|
||||
"export_image": has_points,
|
||||
"generate_script": has_points,
|
||||
"undo": bool(self._undo_stack),
|
||||
@ -2858,7 +2913,7 @@ class MainWindow(QMainWindow):
|
||||
"delete_points": has_selection,
|
||||
"clear_all": has_lines,
|
||||
"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,
|
||||
"background": True,
|
||||
"background_visible": has_loaded_background,
|
||||
|
||||
75
models.py
75
models.py
@ -9,6 +9,7 @@ import mimetypes
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
from collections import deque
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field, replace
|
||||
@ -26,6 +27,7 @@ MAX_NEIGHBOR_TOLERANCE_MM = 2.0
|
||||
METER_LIKE_MAX_SPAN = 10.0
|
||||
_DOCUMENT_KNOWN_FIELDS = {
|
||||
"background",
|
||||
"comments",
|
||||
"circles",
|
||||
"coordinate_unit",
|
||||
"grid_step_mm",
|
||||
@ -311,12 +313,45 @@ class BackgroundImage:
|
||||
)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextComment:
|
||||
id: str
|
||||
text: str
|
||||
x: float
|
||||
y: float
|
||||
extra_payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any]) -> "TextComment":
|
||||
text, x, y = cls.validate(payload.get("text", ""), payload.get("x", 0), payload.get("y", 0))
|
||||
return cls(
|
||||
id=str(payload.get("id") or uuid.uuid4().hex), text=text, x=x, y=y,
|
||||
extra_payload={key: deepcopy(value) for key, value in payload.items()
|
||||
if key not in {"id", "text", "x", "y"}},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate(text: str, x: float, y: float) -> tuple[str, float, float]:
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("Введите текст комментария.")
|
||||
x, y = float(x), float(y)
|
||||
if not math.isfinite(x) or not math.isfinite(y):
|
||||
raise ValueError("Координаты комментария должны быть конечными числами.")
|
||||
return text, x, y
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
text, x, y = self.validate(self.text, self.x, self.y)
|
||||
return {**deepcopy(self.extra_payload), "id": self.id, "text": text, "x": x, "y": y}
|
||||
|
||||
|
||||
class MappingDocument:
|
||||
def __init__(self) -> None:
|
||||
self.path: Path | None = None
|
||||
self.collection_name = "circles"
|
||||
self.points: list[LedPoint] = []
|
||||
self.lines: list[LedLine] = []
|
||||
self.comments: list[TextComment] = []
|
||||
self.background = BackgroundImage()
|
||||
self.unfolding = UnfoldingDescription()
|
||||
self.coordinate_unit = "mm"
|
||||
@ -330,6 +365,7 @@ class MappingDocument:
|
||||
self.collection_name = "circles"
|
||||
self.points.clear()
|
||||
self.lines.clear()
|
||||
self.comments.clear()
|
||||
self.background = BackgroundImage()
|
||||
self.unfolding = UnfoldingDescription()
|
||||
self.coordinate_unit = "mm"
|
||||
@ -385,6 +421,18 @@ class MappingDocument:
|
||||
)
|
||||
|
||||
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._migrate_legacy_index_base(data)
|
||||
@ -553,6 +601,7 @@ class MappingDocument:
|
||||
payload["index_base"] = 0
|
||||
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["comments"] = [comment.to_payload() for comment in self.comments]
|
||||
return payload
|
||||
|
||||
def point_to_payload(self, point: LedPoint, *, id_value: int | None = None) -> dict[str, Any]:
|
||||
@ -596,6 +645,32 @@ class MappingDocument:
|
||||
for line in self.lines
|
||||
]
|
||||
|
||||
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:
|
||||
line_limit = self._line_limit()
|
||||
if len(self.lines) >= line_limit:
|
||||
|
||||
256
qa_smoke.py
256
qa_smoke.py
@ -101,6 +101,13 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
def main() -> 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_supported_json_files,
|
||||
test_load_raw_json_with_trailing_commas_and_bare_decimal_points,
|
||||
@ -3059,7 +3066,7 @@ def test_classic_menu_contains_expected_actions() -> None:
|
||||
"Подложка",
|
||||
"Скрыть боковую панель",
|
||||
],
|
||||
"Инструменты": ["Новая линия", "Новый светодиод", "Сгенерировать скрипт", "Автоблоки", "Против часовой", "Кисть"],
|
||||
"Инструменты": ["Новая линия", "Новый светодиод", "Добавить комментарий…", "Сгенерировать скрипт", "Автоблоки", "Против часовой", "Кисть"],
|
||||
}
|
||||
for top_action in list(window.menuBar().actions()):
|
||||
menu = top_action.menu()
|
||||
@ -3918,6 +3925,9 @@ def test_canvas_context_menu_can_request_edit_and_counterclockwise_block() -> No
|
||||
self.actions.append(action)
|
||||
return action
|
||||
|
||||
def addSeparator(self) -> None:
|
||||
pass
|
||||
|
||||
def exec(self, _global_pos): # noqa: ANN001
|
||||
return next(action for action in self.actions if action.text == self.next_choice)
|
||||
|
||||
@ -4929,5 +4939,249 @@ def fixture_points() -> list[dict[str, int]]:
|
||||
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__":
|
||||
main()
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
"""Текущая версия приложения; обновляется при публикации релиза."""
|
||||
|
||||
__version__ = "3.4.0"
|
||||
__version__ = "3.5.0"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user