diff --git a/README.md b/README.md index c081a75..efdf08f 100644 --- a/README.md +++ b/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 и отображаются в редакторе. + ## Горячие клавиши | Действие | Клавиши | diff --git a/canvas.py b/canvas.py index d60e7df..b62fd27 100644 --- a/canvas.py +++ b/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() diff --git a/docs/superpowers/plans/2026-11-05-text-comments.md b/docs/superpowers/plans/2026-11-05-text-comments.md new file mode 100644 index 0000000..64fb508 --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-text-comments.md @@ -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. Работа ведётся поверх текущих файлов без отката. diff --git a/main_window.py b/main_window.py index 9cee3b3..690b2cc 100644 --- a/main_window.py +++ b/main_window.py @@ -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, diff --git a/models.py b/models.py index e225b68..c61fee7 100644 --- a/models.py +++ b/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: diff --git a/qa_smoke.py b/qa_smoke.py index f90a705..3f33409 100644 --- a/qa_smoke.py +++ b/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("Текст без HTML", 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() diff --git a/version.py b/version.py index 4b96aa9..3a4d008 100644 --- a/version.py +++ b/version.py @@ -1,3 +1,3 @@ """Текущая версия приложения; обновляется при публикации релиза.""" -__version__ = "3.4.0" +__version__ = "3.5.0"