Compare commits

..

1 Commits

Author SHA1 Message Date
30ac54f77f feat: Release v3.5.0 with free-form text comments
- Добавлены свободные многострочные комментарии на схеме: создание через меню, перемещение мышью, редактирование и удаление.
- Реализовано хранение комментариев в JSON с устойчивыми идентификаторами, координатами и сохранением неизвестных полей.
- Все операции с комментариями включены в Undo/Redo; поддержаны схемы только с заметками и сохранение удаления последней заметки.
- Исправлены границы общего вида, неограниченный масштаб схемы с одной заметкой и завершение перетаскивания при панорамировании пробелом.
- Добавлены семь smoke-сценариев, проверки совместимости генератора и обновлено руководство пользователя.
- Версия приложения обновлена до 3.5.0.
2026-11-05 08:50:36 +03:00
7 changed files with 576 additions and 23 deletions

View File

@ -19,7 +19,8 @@ LED Mapper — настольное приложение на Python и PySide6
- Предпросмотр зоны действия кисти и точек, которые будут добавлены. - Предпросмотр зоны действия кисти и точек, которые будут добавлены.
- Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации. - Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации.
- Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении. - Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении.
- Undo/redo для операций разметки. - Свободные многострочные текстовые комментарии на схеме.
- Undo/redo для операций разметки и комментариев.
- Удаление назначений через `Del` и контекстные меню. - Удаление назначений через `Del` и контекстные меню.
- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo. - Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo.
- Сворачивание боковой панели. - Сворачивание боковой панели.
@ -101,6 +102,16 @@ python -m venv .venv
Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование. Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование.
## Текстовые комментарии на схеме
Чтобы разместить заметку, нажмите правой кнопкой мыши в нужном месте схемы и выберите `Добавить комментарий…`. Команда `Инструменты -> Добавить комментарий…` создаёт заметку в центре видимой области. Введите многострочный текст и подтвердите ввод.
Комментарий можно свободно перетаскивать левой кнопкой мыши в любом режиме. Двойной щелчок открывает редактирование текста; в контекстном меню комментария доступны изменение и удаление. Размер текста остаётся читаемым при изменении масштаба. Команда `Показать всё` учитывает комментарии, в том числе за пределами LED-схемы.
Добавление, изменение, перемещение и удаление поддерживают Undo/Redo. Отмена диалога и пустой текст не изменяют схему. Комментарии сохраняются в JSON в списке `comments`: `id`, `text`, `x`, `y`; координаты используют единицы схемы. Старые JSON без этого списка открываются без комментариев. Можно сохранить схему, содержащую только комментарии.
Комментарии не привязаны к линиям, блокам или LED и не влияют на физические адреса в `VM_MAPPINGS`. Экспорт изображения маппинга через меню `Файл` по-прежнему показывает LED и легенду; текстовые комментарии сохраняются в JSON и отображаются в редакторе.
## Горячие клавиши ## Горячие клавиши
| Действие | Клавиши | | Действие | Клавиши |

163
canvas.py
View File

@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QGraphicsRectItem, QGraphicsRectItem,
QGraphicsScene, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsSimpleTextItem,
QGraphicsTextItem,
QGraphicsView, QGraphicsView,
QMenu, QMenu,
QScrollBar, 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): 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] = {}
@ -400,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()
@ -422,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
@ -454,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()
@ -476,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:
@ -624,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
@ -679,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
@ -692,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()
@ -799,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()):
@ -855,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()
@ -905,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()
@ -1277,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()
@ -1287,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")
@ -1338,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()

View File

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

View File

@ -269,6 +269,10 @@ class MainWindow(QMainWindow):
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point) self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise) self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise)
self.canvas.addLedRequested.connect(self.add_manual_led) self.canvas.addLedRequested.connect(self.add_manual_led)
self.canvas.addCommentRequested.connect(self.add_comment_from_canvas)
self.canvas.editCommentRequested.connect(self.edit_comment)
self.canvas.deleteCommentRequested.connect(self.delete_comment)
self.canvas.commentMoveFinished.connect(self.move_comment_from_canvas)
self.canvas.brushStrokeStarted.connect(self.start_brush_stroke) self.canvas.brushStrokeStarted.connect(self.start_brush_stroke)
self.canvas.brushPointTouched.connect(self.add_brush_point_to_block) self.canvas.brushPointTouched.connect(self.add_brush_point_to_block)
self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke) self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke)
@ -374,6 +378,7 @@ class MainWindow(QMainWindow):
self.actions[key] = action self.actions[key] = action
return action return action
add_action("add_comment", "Добавить комментарий…", self.add_comment_at_center)
add_action("new", "Создать...", self.new_file, "file-plus", QKeySequence.New) add_action("new", "Создать...", self.new_file, "file-plus", QKeySequence.New)
add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open) add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open)
add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save) add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save)
@ -553,6 +558,7 @@ class MainWindow(QMainWindow):
tools_menu = menu_bar.addMenu("Инструменты") tools_menu = menu_bar.addMenu("Инструменты")
tools_menu.addAction(self.actions["add_line"]) tools_menu.addAction(self.actions["add_line"])
tools_menu.addAction(self.actions["add_led"]) tools_menu.addAction(self.actions["add_led"])
tools_menu.addAction(self.actions["add_comment"])
tools_menu.addAction(self.actions["generate_script"]) tools_menu.addAction(self.actions["generate_script"])
tools_menu.addSeparator() tools_menu.addSeparator()
tools_menu.addAction(self.actions["auto_blocks"]) tools_menu.addAction(self.actions["auto_blocks"])
@ -1718,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)
@ -2838,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),
@ -2858,7 +2913,7 @@ class MainWindow(QMainWindow):
"delete_points": has_selection, "delete_points": has_selection,
"clear_all": has_lines, "clear_all": has_lines,
"clear_selection": has_selection, "clear_selection": has_selection,
"fit_view": has_points or has_visible_background, "fit_view": has_points or has_visible_background or bool(self.document.comments),
"grid": True, "grid": True,
"background": True, "background": True,
"background_visible": has_loaded_background, "background_visible": has_loaded_background,

View File

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

View File

@ -101,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,
@ -3059,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()
@ -3918,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)
@ -4929,5 +4939,249 @@ def fixture_points() -> list[dict[str, int]]:
return points return points
def test_text_comments_roundtrip_and_legacy() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "comments.json"
payload = {"circles": [], "coordinate_unit": "mm", "index_base": 0,
"custom": {"keep": True}, "comments": [
{"id": "note-1", "text": "Питание <24В>\nВторая строка 🔌",
"x": -12.125, "y": 34.75, "custom_style": {"color": "amber"}}]}
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
assert hasattr(doc, "comments"), "Документ должен загружать текстовые комментарии"
assert len(doc.comments) == 1
note = doc.comments[0]
assert (note.id, note.x, note.y, note.text) == ("note-1", -12.125, 34.75, payload["comments"][0]["text"])
doc.save(path)
saved = json.loads(path.read_text(encoding="utf-8"))
assert saved["comments"] == payload["comments"]
assert saved["custom"] == payload["custom"]
loaded = MappingDocument()
loaded.load(path)
assert loaded.to_payload()["comments"] == saved["comments"]
loaded.clear()
assert loaded.comments == []
path.write_text('{"circles": []}', encoding="utf-8")
loaded.load(path)
assert loaded.comments == []
def test_text_comments_validate_and_keep_identity() -> None:
doc = MappingDocument()
assert hasattr(doc, "add_comment"), "Нужны операции модели для комментариев"
first = doc.add_comment("Первый", -1.5, 2.75)
second = doc.add_comment("Второй", 3.0, -4.0)
doc.remove_comment(first.id)
assert doc.get_comment(second.id) is second
third = doc.add_comment("Третий", 0, 0)
assert len({first.id, second.id, third.id}) == 3
doc.update_comment(second.id, text="Изменён", x=-0.034, y=0.068)
assert (second.text, second.x, second.y) == ("Изменён", -0.034, 0.068)
for kwargs in ({"text": " "}, {"x": float("nan")}, {"y": float("inf")}):
before = deepcopy(second)
try:
doc.update_comment(second.id, **kwargs)
except ValueError:
pass
else:
raise AssertionError("Недопустимые комментарии должны отклоняться")
assert second == before
def test_text_comments_gui_history_and_cancel() -> None:
if MainWindow is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
try:
with patch("app.main_window.QInputDialog.getMultiLineText", return_value=("Заметка\n24В", True)):
window.add_comment_from_canvas(-34.5, 68.25)
assert len(window.document.comments) == 1
note_id = window.document.comments[0].id
assert window._dirty
assert window.actions["save"].isEnabled()
assert note_id in window.canvas.comment_items
with patch("app.main_window.QInputDialog.getMultiLineText", return_value=("Новый текст", True)):
window.edit_comment(note_id)
window.move_comment_from_canvas(note_id, 102.25, -136.5)
assert window.document.get_comment(note_id).x == 102.25
window.undo_last_change()
assert window.document.get_comment(note_id).x == -34.5
window.redo_last_change()
assert window.document.get_comment(note_id).x == 102.25
window.delete_comment(note_id)
assert window.document.comments == []
window.undo_last_change()
assert window.document.get_comment(note_id).text == "Новый текст"
before = len(window._undo_stack)
for result in (("Отмена", False), (" \n ", True)):
with patch("app.main_window.QInputDialog.getMultiLineText", return_value=result):
window.add_comment_from_canvas(0, 0)
window.edit_comment(note_id)
assert len(window._undo_stack) == before
assert len(window.document.comments) == 1
assert window.document.get_comment(note_id).text == "Новый текст"
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "only_comments.json"
assert window._save_to(path)
window.delete_comment(note_id)
assert window.actions["save"].isEnabled(), "Нужно сохранять удаление последнего комментария"
window.save_file()
loaded = MappingDocument()
loaded.load(path)
assert loaded.comments == []
finally:
window._dirty = False
window.close()
app.processEvents()
def test_text_comments_canvas_drag_and_units() -> None:
if MainWindow is None:
return
app = QApplication.instance() or QApplication([])
for unit, scale in (("mm", 1.0), ("m", 0.001)):
window = MainWindow()
try:
doc = MappingDocument()
doc.coordinate_unit = unit
doc.add_manual_point(0, 0)
doc.add_manual_point(340 * scale, 340 * scale)
note = doc.add_comment("Текст <b>без HTML</b>", 68 * scale, 136 * scale)
window.document = doc
window.canvas.set_document(doc)
window.show()
app.processEvents()
canvas = window.canvas
item = canvas.comment_items[note.id]
assert item.toPlainText() == note.text
assert item.pos() == canvas._to_scene_point(note.x, note.y)
before = len(window._undo_stack)
start = canvas.mapFromScene(item.pos()) + QPoint(12, 12)
end = start + QPoint(45, 30)
expected = canvas._document_point_from_scene(item.pos() + canvas.mapToScene(end) - canvas.mapToScene(start))
QTest.mousePress(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, start)
QTest.mouseMove(canvas.viewport(), end)
QTest.mouseRelease(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, end)
app.processEvents()
moved = window.document.get_comment(note.id)
assert abs(moved.x - expected.x()) < 1e-8
assert abs(moved.y - expected.y()) < 1e-8
assert len(window._undo_stack) == before + 1
window.undo_last_change()
restored = window.document.get_comment(note.id)
assert (restored.x, restored.y) == (68 * scale, 136 * scale)
item = canvas.comment_items[note.id]
at = canvas.mapFromScene(item.pos()) + QPoint(10, 10)
with patch("app.main_window.QInputDialog.getMultiLineText", return_value=("Двойной щелчок", True)):
QTest.mouseDClick(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, at)
assert window.document.get_comment(note.id).text == "Двойной щелчок"
canvas.set_instant_space_pan(True)
item = canvas.comment_items[note.id]
origin = QPointF(item.pos())
at = canvas.mapFromScene(origin) + QPoint(12, 12)
QTest.mousePress(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, at)
QTest.keyPress(canvas, Qt.Key_Space)
QTest.mouseMove(canvas.viewport(), at + QPoint(30, 20))
QTest.mouseRelease(canvas.viewport(), Qt.LeftButton, Qt.NoModifier, at + QPoint(30, 20))
QTest.keyRelease(canvas, Qt.Key_Space)
assert canvas._comment_drag is None
QTest.mouseMove(canvas.viewport(), at + QPoint(60, 40))
assert item.pos() == origin
finally:
window._dirty = False
window.close()
app.processEvents()
def test_text_comments_fit_and_context_menu() -> None:
if LedCanvas is None:
return
app = QApplication.instance() or QApplication([])
canvas = LedCanvas()
canvas.resize(1000, 700)
doc = MappingDocument()
doc.add_manual_point(0, 0)
doc.add_manual_point(50000, 50000)
note = doc.add_comment("Дальний комментарий\nСтрока 2", 80000, 25000)
canvas.set_document(doc)
canvas.show()
app.processEvents()
canvas.fit_to_content()
item = canvas.comment_items[note.id]
visible_rect = item.deviceTransform(canvas.viewportTransform()).mapRect(item.boundingRect())
assert QRectF(canvas.viewport().rect()).adjusted(-2, -2, 2, 2).contains(visible_rect), visible_rect
from PySide6.QtGui import QContextMenuEvent
at = canvas.mapFromScene(item.pos()) + QPoint(8, 8)
emitted = []
canvas.editCommentRequested.connect(lambda key: emitted.append(("edit", key)))
canvas.deleteCommentRequested.connect(lambda key: emitted.append(("delete", key)))
original_menu = canvas_module.QMenu
class TestMenu(original_menu):
choice = 0
def exec(self, _pos):
return self.actions()[self.choice]
for index, expected in ((0, "edit"), (1, "delete")):
TestMenu.choice = index
with patch.object(canvas_module, "QMenu", TestMenu):
event = QContextMenuEvent(QContextMenuEvent.Mouse, at, canvas.viewport().mapToGlobal(at))
canvas.contextMenuEvent(event)
assert emitted[-1] == (expected, note.id)
canvas.addCommentRequested.connect(lambda x, y: emitted.append(("add", x, y)))
at = QPoint(30, 30)
expected = canvas._document_point_from_scene(canvas.mapToScene(at))
TestMenu.choice = 0
with patch.object(canvas_module, "QMenu", TestMenu):
canvas.contextMenuEvent(QContextMenuEvent(QContextMenuEvent.Mouse, at, canvas.viewport().mapToGlobal(at)))
assert emitted[-1] == ("add", expected.x(), expected.y())
canvas.close()
def test_text_comments_do_not_change_physical_mapping() -> None:
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
doc.add_block(line.id, [0, 1, 2])
before = generate_panel_mapping_from_document(doc, Path(temp_dir) / "before")
note = doc.add_comment("Контроль питания", -100.5, 200.25)
after = generate_panel_mapping_from_document(doc, Path(temp_dir) / "after")
assert set(after.generated_files) == set(before.generated_files)
assert {p.name for p in after.generated_files.values()} == {
"vm_map.h", "ordered_circles.json", "validation_report.txt", "README.md"}
assert not (after.output_dir / "virtual_matrix_legacy.ino.txt").exists()
first = json.loads((before.output_dir / "ordered_circles.json").read_text(encoding="utf-8"))
second = json.loads((after.output_dir / "ordered_circles.json").read_text(encoding="utf-8"))
assert first["circles"] == second["circles"]
assert after.assigned_count == before.assigned_count == 3
assert doc.get_comment(note.id).x == -100.5
header_before = (before.output_dir / "vm_map.h").read_text(encoding="utf-8")
header_after = (after.output_dir / "vm_map.h").read_text(encoding="utf-8")
assert header_before[header_before.index("#define VM_ROW_FROM_GRID_COL"):] == header_after[header_after.index("#define VM_ROW_FROM_GRID_COL"):]
def test_text_comments_only_fit_stays_stable() -> None:
if LedCanvas is None:
return
app = QApplication.instance() or QApplication([])
doc = MappingDocument()
doc.add_comment("Одна заметка", 10, 20)
canvas = LedCanvas()
canvas.resize(1000, 700)
canvas.set_document(doc)
canvas.show()
app.processEvents()
for _ in range(5):
canvas.fit_to_content()
assert 0.01 <= canvas.transform().m11() <= 2.0
canvas.close()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

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