diff --git a/PR_SUMMARY.md b/PR_SUMMARY.md new file mode 100644 index 00000000..c94b02d9 --- /dev/null +++ b/PR_SUMMARY.md @@ -0,0 +1,64 @@ +# Summary of changes (for PR reply) + +## What this PR does + +This PR adds **forward-looking rewrite highlighting** in proof mode: when you select a proof step, the graph highlights **what will change in the next step** (the vertices and edges affected by that rewrite), instead of what changed in the previous one. It also adds a **GUI toggle** to turn this highlighting on or off, and ensures **only the relevant vertices/edges** are highlighted (no coordinate drift or unrelated nodes). + +--- + +## 1. Highlight behaviour + +- **Forward-looking**: Step *i* shows the graph at *i* and highlights the transition to step *i*+1 (the next rewrite). +- **Semantic, not structural**: We no longer use a generic graph diff. We store a small amount of metadata when a rewrite is applied and use that to highlight: + - **Match-based** (e.g. Spider Fusion): we store `highlight_match_pairs` — the vertex pairs `(v1, v2)` that were matched. When displaying the step, we highlight those two vertices and only the edge between them. + - **Vertex-based** (unfuse, color change, strong complementarity, remove identity, etc.): we store `highlight_verts` — the vertex IDs involved. We highlight those vertices and, if there are exactly two, only the edge between them; otherwise all incident edges. + +So the logic is: “when this step was created, we recorded which vertices (and for fuse, which pair) were involved; when we show this step, we highlight those in the current graph.” + +--- + +## 2. Where metadata is set + +- **Rewrite tree / drag-and-drop** + In `rewrite_action.py`, when the “Fuse spiders” rule is applied we set `highlight_match_pairs` from the match `(v1, v2)`. + In `proof_panel.py`, drag-and-drop and double-click handlers set `highlight_match_pairs` or `highlight_verts` (e.g. “Fuse spiders” → `[(v,w)]`, “Strong complementarity” → `[v,w]`, “Color change” / “Remove identity” → `[v]`, “unfuse” → list of split vertex IDs). + +- **Commands** + `AddRewriteStep` in `commands.py` takes optional `highlight_match_pairs` and `highlight_verts` and passes them into the new `Rewrite` stored in the proof model. + +- **Proof model** + Each `Rewrite` in `proof.py` can carry `highlight_match_pairs` and `highlight_verts`. When the user selects a step, `ProofStepView.move_to_step` uses this metadata to compute the sets of vertices and edges to highlight and calls `scene.set_rewrite_highlight(verts, edges)`. + +--- + +## 3. GUI toggle + +- **View menu**: “Show rewrite highlights” checkable action; toggling it turns highlighting on/off and persists the setting. +- **Preferences**: “Highlight rewrite steps” in the General tab (same setting). +- When the setting is off, `move_to_step` clears the highlight and does not apply any of the above logic. When the setting is changed (from the menu or Preferences), the current step’s highlight is refreshed so the graph updates immediately. + +--- + +## 4. Lint fixes (mypy) + +- **proof.py**: Resolved `no-redef` for the edge set used in the vertex-based branch by introducing a single `edges_highlight` variable and assigning it in the two branches (either from `_edges_between` or by building the set of incident edges). +- **commands.py**: `AddRewriteStep.step_view` is now typed as `ProofStepView` instead of `QListView`, so mypy recognizes `move_to_step`. +- **app.py**: Added `# type: ignore[attr-defined]` for `setColorScheme` on `QStyleHints` so that `mypy zxlive` passes (stubs may not define it yet). + +--- + +## Files touched (overview) + +| File | Role | +|------|------| +| `zxlive/proof.py` | `Rewrite` metadata; `move_to_step` highlight logic; `_edges_between` helper; settings check. | +| `zxlive/commands.py` | `AddRewriteStep` carries and passes highlight metadata; typed as `ProofStepView`. | +| `zxlive/proof_panel.py` | Sets `highlight_match_pairs` / `highlight_verts` when creating rewrite steps (drag-drop, double-click, Magic Wand, unfuse). | +| `zxlive/rewrite_action.py` | Sets `highlight_match_pairs` for “Fuse spiders” from the rewrite tree. | +| `zxlive/graphscene.py` | `set_rewrite_highlight` / `clear_rewrite_highlight`; scene stores and uses highlighted verts/edges. | +| `zxlive/vitem.py` / `zxlive/eitem.py` | Drawing uses scene’s highlight state for vertices and edges. | +| `zxlive/mainwindow.py` | View menu toggle and `refresh_rewrite_highlight()`. | +| `zxlive/settings.py` / `zxlive/settings_dialog.py` | “Highlight rewrite steps” setting and persistence. | +| `test/test_highlight.py` | Test that step selection and toggle affect highlights correctly. | + +You can paste the “Summary of changes” and “Lint fixes” sections (or a shortened version) into the PR as the explanation for reviewers. diff --git a/pyproject.toml b/pyproject.toml index 7c95e65c..443392c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "numpy", "shapely", "pyperclip", + "galois", "imageio", "packaging", "matplotlib>=3.3", @@ -72,6 +73,10 @@ reportWildcardImportFromLibrary = "none" [tool.ruff] line-length = 120 +exclude = [ + ".venv311", + "build", +] [tool.ruff.lint] select = [ @@ -101,3 +106,7 @@ disallow_untyped_defs = true disable_error_code = [ "import", ] +exclude = [ + "^build/", + "^test/", +] diff --git a/requirements.txt b/requirements.txt index fb5478bc..8049b09c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ PySide6~=6.7.2 shapely~=2.0.1 shapely-stubs @ git+https://github.com/ciscorn/shapely-stubs.git pyperclip>=1.8.1 +galois mypy pylint pyproject-flake8 diff --git a/test/test_highlight.py b/test/test_highlight.py new file mode 100644 index 00000000..7161efef --- /dev/null +++ b/test/test_highlight.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import copy +import sys +import types + +import pytest +from PySide6 import QtCore +from pytestqt.qtbot import QtBot +from pyzx.utils import EdgeType, VertexType + + +# Ensure compatibility with pyzx versions used by ZXLive. We only patch things +# that are missing, and leave real implementations untouched when they exist. +import pyzx + +try: # pragma: no cover - defensive shims for newer pyzx + import pyzx.tikz as _pyzx_tikz # type: ignore[import] + if not hasattr(_pyzx_tikz, "synonyms_dummy"): + _pyzx_tikz.synonyms_dummy = () # type: ignore[attr-defined] + + import pyzx.settings as _pyzx_settings # type: ignore[import] + tc = getattr(_pyzx_settings, "tikz_classes", None) + if not isinstance(tc, dict) or "dummy" not in tc: + tc = dict(tc or {}) + tc.setdefault("dummy", "") + _pyzx_settings.tikz_classes = tc # type: ignore[attr-defined] +except Exception: + pass + + +# Newer versions of pyzx no longer expose the historical `pyzx.rewrite` module, +# but ZXLive still imports `RewriteSimpGraph` from there. Try to import the real +# module first; if that fails, provide a minimal compatibility shim. +try: # pragma: no cover - prefer real module if available + import pyzx.rewrite as _pyzx_rewrite # type: ignore[import] +except Exception: # pragma: no cover - fallback shim + if "pyzx.rewrite" not in sys.modules: + rewrite_module = types.ModuleType("pyzx.rewrite") + + class RewriteSimpGraph: # type: ignore[override] + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def __class_getitem__(cls, item: object) -> type: # type: ignore[override] + return cls + + rewrite_module.RewriteSimpGraph = RewriteSimpGraph # type: ignore[attr-defined] + rewrite_module.Rewrite = object # type: ignore[attr-defined] + sys.modules["pyzx.rewrite"] = rewrite_module + + +from zxlive.commands import AddRewriteStep +from zxlive.common import GraphT, new_graph +from zxlive.edit_panel import GraphEditPanel +from zxlive.mainwindow import MainWindow +from zxlive.proof_panel import ProofPanel +from zxlive.settings import display_setting + + +def make_two_spider_graph() -> tuple[GraphT, GraphT]: + """Create initial graph (2 Z spiders, phase 0 and 0.5, connected) and fused graph.""" + g = new_graph() + + v0 = g.add_vertex(VertexType.Z, 0, 0.0) + v1 = g.add_vertex(VertexType.Z, 0, 1.0) + + # We rely on these being 0 and 1 for clarity in assertions below. + assert v0 == 0 + assert v1 == 1 + + g.set_phase(v1, 0.5) + g.add_edge((v0, v1), EdgeType.SIMPLE) + + g_fused = copy.deepcopy(g) + pyzx.rewrite_rules.fuse(g_fused, v0, v1) + + return g, g_fused + + +@pytest.fixture +def app(qtbot: QtBot) -> MainWindow: + mw = MainWindow() + qtbot.addWidget(mw) + return mw + + +def test_rewrite_highlight_set_and_cleared_on_step_change(app: MainWindow, qtbot: QtBot) -> None: + initial_graph, fused_graph = make_two_spider_graph() + + # Open a new tab with our simple two-spider graph. + app.new_graph(initial_graph, name="Highlight Test") + assert app.active_panel is not None + assert isinstance(app.active_panel, GraphEditPanel) + edit_panel: GraphEditPanel = app.active_panel + + # Start a derivation from this graph to enter proof mode. + qtbot.mouseClick(edit_panel.start_derivation, QtCore.Qt.MouseButton.LeftButton) + assert app.active_panel is not None + assert isinstance(app.active_panel, ProofPanel) + proof_panel: ProofPanel = app.active_panel + + # Ensure rewrite highlighting is on so the test is independent of user settings. + display_setting.highlight_rewrites = True + + # Add a Fuse spiders rewrite with match pair so step 0 highlights the fused vertices/edge. + cmd = AddRewriteStep( + proof_panel.graph_view, fused_graph, proof_panel.step_view, "Fuse spiders", + highlight_match_pairs=[(0, 1)], + ) + proof_panel.undo_stack.push(cmd) + + scene = proof_panel.graph_scene + + # "Next change" semantics: on START (index 0) we show the graph at step 0 + # and highlight what will change in the transition 0 -> 1 (the fuse). + proof_panel.step_view.move_to_step(0) + # Both spiders that will be fused, and the edge between them, should be highlighted. + assert scene.is_vertex_highlighted(0) + assert scene.is_vertex_highlighted(1) + edges_01 = list(scene.g.edges(0, 1)) + assert len(edges_01) >= 1 + e = edges_01[0] + s, t = scene.g.edge_st(e) + edge_01 = (s, t, scene.g.edge_type(e)) + assert scene.is_edge_highlighted(edge_01) + + # On the last step (index 1) there is no "next" transition, so no highlight. + proof_panel.step_view.move_to_step(1) + assert not any(scene.is_vertex_highlighted(v) for v in scene.g.vertices()) + + # Back to START: again highlight the next change (0 -> 1). + proof_panel.step_view.move_to_step(0) + assert scene.is_vertex_highlighted(0) + assert scene.is_vertex_highlighted(1) + + # Now simulate the user using Undo to remove the rewrite step entirely. + # When the proof returns to the START state via the undo stack, there + # should again be no rewrite highlighting at all. + proof_panel.undo_stack.undo() + assert proof_panel.step_view.currentIndex().row() == 0 + assert not any(scene.is_vertex_highlighted(v) for v in scene.g.vertices()) + diff --git a/zxlive/animations.py b/zxlive/animations.py index 7c5f92dd..83e8dfa5 100644 --- a/zxlive/animations.py +++ b/zxlive/animations.py @@ -305,23 +305,38 @@ def make_animation(self: RewriteAction, panel: ProofPanel, g: GraphT, matches: l for v in rem_verts: anim_before.addAnimation(remove_id(panel.graph_scene.vertex_map[v])) elif self.name == rules_basic['copy']['text']: + # COPY is MATCH_SINGLE: matches is a list of vertices. We animate the + # neighbor each spider is being copied through. anim_before = QParallelAnimationGroup() for v in matches: w = list(panel.graph.neighbors(v))[0] - anim_before.addAnimation(fuse(panel.graph_scene.vertex_map[v], - panel.graph_scene.vertex_map[w])) + anim_before.addAnimation( + fuse(panel.graph_scene.vertex_map[v], + panel.graph_scene.vertex_map[w]) + ) anim_after = QParallelAnimationGroup() for v in matches: w = list(panel.graph.neighbors(v))[0] - anim_after.addAnimation(strong_comp(panel.graph, g, w, panel.graph_scene)) + anim_after.addAnimation( + strong_comp(panel.graph, g, w, panel.graph_scene) + ) elif self.name == rules_basic['pauli']['text']: + # PAULI is MATCH_DOUBLE: matches is a list of (v1, v2) pairs. anim_before = QParallelAnimationGroup() for m in matches: - anim_before.addAnimation(fuse(panel.graph_scene.vertex_map[m[0]], - panel.graph_scene.vertex_map[m[1]])) + if isinstance(m, tuple) and len(m) == 2: + v1, v2 = m + anim_before.addAnimation( + fuse(panel.graph_scene.vertex_map[v1], + panel.graph_scene.vertex_map[v2]) + ) anim_after = QParallelAnimationGroup() for m in matches: - anim_after.addAnimation(strong_comp(panel.graph, g, m[1], panel.graph_scene)) + if isinstance(m, tuple) and len(m) == 2: + _, v2 = m + anim_after.addAnimation( + strong_comp(panel.graph, g, v2, panel.graph_scene) + ) elif self.name == rules_basic['bialgebra']['text']: anim_before = QParallelAnimationGroup() for v1, v2 in matches: diff --git a/zxlive/app.py b/zxlive/app.py index 9e3a2e6f..271a481e 100644 --- a/zxlive/app.py +++ b/zxlive/app.py @@ -147,8 +147,8 @@ def main() -> None: zxl = ZXLive() if sys.platform == "darwin": # 'darwin' is macOS if dark_mode_setting == "dark": - zxl.styleHints().setColorScheme(Qt.ColorScheme.Dark) + zxl.styleHints().setColorScheme(Qt.ColorScheme.Dark) # type: ignore[attr-defined] elif dark_mode_setting == "light": - zxl.styleHints().setColorScheme(Qt.ColorScheme.Light) + zxl.styleHints().setColorScheme(Qt.ColorScheme.Light) # type: ignore[attr-defined] # For "system", don't set the color scheme to let Qt auto-detect zxl.exec_() diff --git a/zxlive/base_panel.py b/zxlive/base_panel.py index 080c5b28..46e87d54 100644 --- a/zxlive/base_panel.py +++ b/zxlive/base_panel.py @@ -138,8 +138,7 @@ def set_splitter_size(self) -> None: def update_font(self) -> None: self.graph_view.update_font() - # TODO: Fix code complexity - # noqa: complexipy + # TODO: Fix code complexity # noqa: complexipy def show_matrix(self) -> None: """Show the matrix of the current graph in a dialog.""" from PySide6.QtCore import Qt diff --git a/zxlive/commands.py b/zxlive/commands.py index 630939c7..15a284bf 100644 --- a/zxlive/commands.py +++ b/zxlive/commands.py @@ -6,10 +6,7 @@ from fractions import Fraction from typing import Callable, Iterable, Optional, Set, Union -from PySide6.QtCore import QModelIndex from PySide6.QtGui import QUndoCommand -from PySide6.QtWidgets import QListView -from pyzx.graph.diff import GraphDiff from pyzx.symbolic import Poly from pyzx.utils import EdgeType, VertexType, get_w_partner, vertex_is_w, get_w_io, get_z_box_label, set_z_box_label @@ -445,9 +442,11 @@ class AddRewriteStep(UpdateGraph): The rewrite is inserted after the currently selected step. In particular, it replaces all rewrites that were previously after the current selection. """ - step_view: QListView + step_view: ProofStepView name: str - diff: Optional[GraphDiff] = None + highlight_match_pairs: Optional[list[tuple[int, int]]] = None + highlight_verts: Optional[list[int]] = None + highlight_edge_pairs: Optional[list[tuple[int, int]]] = None _old_selected: Optional[int] = field(default=None, init=False) _old_steps: list[tuple[Rewrite, GraphT]] = field(default_factory=list, init=False) @@ -465,14 +464,18 @@ def redo(self) -> None: for _ in range(self.proof_model.rowCount() - self._old_selected - 1): self._old_steps.append(self.proof_model.pop_rewrite()) - self.proof_model.add_rewrite(Rewrite(self.name, self.name, self.new_g)) + hp_list = list(self.highlight_match_pairs) if self.highlight_match_pairs else None + hv_list = list(self.highlight_verts) if self.highlight_verts else None + he_list = list(self.highlight_edge_pairs) if self.highlight_edge_pairs else None + self.proof_model.add_rewrite( + Rewrite(self.name, self.name, self.new_g, None, hp_list, hv_list, he_list) + ) - # Select the added step - idx = self.step_view.model().index(self.proof_model.rowCount() - 1, 0, QModelIndex()) - self.step_view.selectionModel().blockSignals(True) - self.step_view.setCurrentIndex(idx) - self.step_view.selectionModel().blockSignals(False) + # Move to the added step so that the graph view and rewrite-step + # highlighting are updated consistently. + new_index = self.proof_model.rowCount() super().redo() + self.step_view.move_to_step(new_index - 1) def undo(self) -> None: # Undo the rewrite @@ -480,18 +483,19 @@ def undo(self) -> None: self.proof_model.pop_rewrite() self.step_view.selectionModel().blockSignals(False) - # Add back steps that were previously removed + # Add back steps that were previously removed. for rewrite, graph in reversed(self._old_steps): self.proof_model.add_rewrite(rewrite) - # Select the previously selected step - assert self._old_selected is not None - idx = self.step_view.model().index(self._old_selected, 0, QModelIndex()) - self.step_view.selectionModel().blockSignals(True) - self.step_view.setCurrentIndex(idx) - self.step_view.selectionModel().blockSignals(False) + # First restore the underlying graph to its previous state. super().undo() + # Then move the proof view back to the previously selected step so that + # the graph view and rewrite-step highlighting are updated consistently + # (including clearing highlights at START). + assert self._old_selected is not None + self.step_view.move_to_step(self._old_selected) + @dataclass class GroupRewriteSteps(BaseCommand): diff --git a/zxlive/common.py b/zxlive/common.py index 733c8093..6b3d8562 100644 --- a/zxlive/common.py +++ b/zxlive/common.py @@ -30,8 +30,7 @@ def set_settings_value(arg: str, val: T, _type: Type[T], settings: QSettings | N _settings.setValue(arg, val) -# TODO: Fix code complexity -# noqa: complexipy +# TODO: Fix code complexity # noqa: complexipy def get_settings_value(arg: str, _type: Type[T], default: T | None = None, settings: QSettings | None = None) -> T: _settings = settings or QSettings("zxlive", "zxlive") try: diff --git a/zxlive/custom_rule.py b/zxlive/custom_rule.py index 0f856403..9abf10e2 100644 --- a/zxlive/custom_rule.py +++ b/zxlive/custom_rule.py @@ -33,14 +33,18 @@ def __init__(self, lhs_graph: GraphT, rhs_graph: GraphT, name: str, description: self.name = name self.description = description self.last_rewrite_center = None + self.last_rewrite_verts: Optional[list[VT]] = None self.is_rewrite_unfusable = is_rewrite_unfusable(lhs_graph) if self.is_rewrite_unfusable: - self.lhs_graph_without_boundaries_nx = nx.MultiGraph(self.lhs_graph_nx.subgraph( - [v for v in self.lhs_graph_nx.nodes() if self.lhs_graph_nx.nodes()[v]['type'] != VertexType.BOUNDARY])) + self.lhs_graph_without_boundaries_nx = nx.MultiGraph( + self.lhs_graph_nx.subgraph( + [v for v in self.lhs_graph_nx.nodes() if self.lhs_graph_nx.nodes()[v]['type'] != VertexType.BOUNDARY] + ) + ) # TODO: Fix code complexity # noqa: complexipy - def applier(self, graph: BaseGraph[VT, ET], vertices: list[VT]) -> bool: # noqa: PLR0912 + def applier(self, graph: BaseGraph[VT, ET], vertices: list[VT]) -> bool: # noqa: PLR0912 # pylint: disable=too-many-locals,too-many-branches assert isinstance(graph, GraphT) if self.is_rewrite_unfusable: self.unfuse_subgraph_for_rewrite(graph, vertices) @@ -98,6 +102,13 @@ def applier(self, graph: BaseGraph[VT, ET], vertices: list[VT]) -> bool: # noqa etab[(v1, v2)][data['type'] - 1] += 1 graph.add_edge_table(etab) + # Prefer non-boundary RHS vertices so highlight is limited to the gadget region; + # fall back to all mapped vertices if there are no non-boundary (e.g. degenerate rule). + non_boundary = [ + vertex_map[v] for v in self.rhs_graph_nx.nodes() + if self.rhs_graph_nx.nodes()[v].get('type') != VertexType.BOUNDARY + ] + self.last_rewrite_verts = non_boundary if non_boundary else list(vertex_map.values()) graph.remove_vertices(vertices_to_remove) return True diff --git a/zxlive/eitem.py b/zxlive/eitem.py index 2d83ff65..a110f1fb 100644 --- a/zxlive/eitem.py +++ b/zxlive/eitem.py @@ -106,6 +106,12 @@ def refresh(self) -> None: if self.g.edge_type(self.e) == EdgeType.HADAMARD: pen.setDashPattern([4.0, 2.0]) pen.setColor(self.color) + + if self.graph_scene.is_edge_highlighted(self.e): + # Emphasize highlighted edges with a thicker, accent-colored pen. + pen.setWidthF(self.thickness + 2.0) + pen.setColor(display_setting.effective_colors["rewrite_highlight_edge"]) + self.setPen(QPen(pen)) if not self.is_dragging: diff --git a/zxlive/graphscene.py b/zxlive/graphscene.py index 41ee28bf..83433d20 100644 --- a/zxlive/graphscene.py +++ b/zxlive/graphscene.py @@ -62,6 +62,8 @@ def __init__(self) -> None: self.vertex_map: dict[VT, VItem] = {} self.edge_map: dict[ET, dict[int, EItem]] = {} self.g: GraphT + self._highlighted_verts: set[VT] = set() + self._highlighted_edges: set[ET] = set() def update_background_brush(self) -> None: if display_setting.dark_mode: @@ -97,6 +99,44 @@ def select_vertices(self, vs: Iterable[VT]) -> None: vs.remove(it.v) self.selection_changed_custom.emit() + def set_rewrite_highlight(self, verts: Iterable[VT], edges: Iterable[ET]) -> None: + """Set the vertices and edges that should be highlighted as part of a rewrite step.""" + new_verts = set(verts) + new_edges = set(edges) + + # Capture old highlight sets so we can refresh anything whose + # highlight status may have changed. + old_verts = self._highlighted_verts + old_edges = self._highlighted_edges + + # Update internal highlight state *before* refreshing items so that + # VItem.refresh / EItem.refresh see the new highlight flags. + self._highlighted_verts = new_verts + self._highlighted_edges = new_edges + + # Refresh vertices whose highlight status changed + for v in old_verts.union(new_verts): + if v in self.vertex_map: + self.vertex_map[v].refresh() + + # Refresh edges whose highlight status changed + for e in old_edges.union(new_edges): + if e in self.edge_map: + for e_item in self.edge_map[e].values(): + e_item.refresh() + + self.update() + + def clear_rewrite_highlight(self) -> None: + """Clear all rewrite-step highlighting.""" + self.set_rewrite_highlight(set(), set()) + + def is_vertex_highlighted(self, v: VT) -> bool: + return v in self._highlighted_verts + + def is_edge_highlighted(self, e: ET) -> bool: + return e in self._highlighted_edges + def set_graph(self, g: GraphT) -> None: """Set the PyZX graph for the scene. If the scene already contains a graph, it will be replaced.""" diff --git a/zxlive/graphview.py b/zxlive/graphview.py index a51d52de..8d0b72d9 100644 --- a/zxlive/graphview.py +++ b/zxlive/graphview.py @@ -178,8 +178,7 @@ def keyPressEvent(self, e: QKeyEvent) -> None: else: super().keyPressEvent(e) - # TODO: Fix code complexity - # noqa: complexipy + # TODO: Fix code complexity # noqa: complexipy def mouseMoveEvent(self, e: QMouseEvent) -> None: # noqa: PLR0912 super().mouseMoveEvent(e) if self.tool == GraphTool.Selection: @@ -214,8 +213,7 @@ def mouseMoveEvent(self, e: QMouseEvent) -> None: # noqa: PLR0912 else: e.ignore() - # TODO: Fix code complexity - # noqa: complexipy + # TODO: Fix code complexity # noqa: complexipy def mouseReleaseEvent(self, e: QMouseEvent) -> None: # noqa: PLR0912 if e.button() == Qt.MouseButton.RightButton and self.graph_scene.selectedItems(): return diff --git a/zxlive/mainwindow.py b/zxlive/mainwindow.py index 3abbfda9..cc22c0e3 100644 --- a/zxlive/mainwindow.py +++ b/zxlive/mainwindow.py @@ -19,7 +19,7 @@ import json import logging import random -from typing import Callable, Optional, cast +from typing import Callable, Optional, cast, TYPE_CHECKING import pyperclip from PySide6.QtCore import (QByteArray, QEvent, QFile, QFileInfo, QIODevice, @@ -52,6 +52,9 @@ from .sfx import SFXEnum, load_sfx from .tikz import proof_to_tikz +if TYPE_CHECKING: + from PySide6.QtMultimedia import QSoundEffect + class MainWindow(QMainWindow): """The main window of the ZXLive application.""" @@ -223,6 +226,11 @@ def __init__(self) -> None: view_menu.addAction(self.zoom_in_action) view_menu.addAction(self.zoom_out_action) view_menu.addAction(self.fit_view_action) + self.show_rewrite_highlights_action = QAction("Show rewrite highlights", self) + self.show_rewrite_highlights_action.setCheckable(True) + self.show_rewrite_highlights_action.setChecked(get_settings_value("highlight-rewrites", bool)) + self.show_rewrite_highlights_action.triggered.connect(self._toggle_rewrite_highlights) + view_menu.addAction(self.show_rewrite_highlights_action) new_rewrite_from_file = self._new_action( "New rewrite from file", lambda: create_new_rewrite(self), @@ -252,7 +260,11 @@ def __init__(self) -> None: menu.setStyleSheet("QMenu::item:disabled { color: gray }") self._reset_menus(False) - self.effects = {e: load_sfx(e) for e in SFXEnum} + # Lazy load SFX to avoid Qt multimedia backend issues in headless/CI. + # (Tests run with sound-effects disabled by default.) + self.effects: dict[SFXEnum, "QSoundEffect"] = {} + if self.sfx_on: + self.effects = {e: load_sfx(e) for e in SFXEnum} QShortcut(QKeySequence("Ctrl+B"), self).activated.connect( self._toggle_sfx) @@ -420,7 +432,9 @@ def _restore_session_state(self) -> bool: # noqa: PLR0912 return False try: - session_data = json.loads(session_json) + # QSettings.value can return various types; ensure we pass a str/bytes-like + # object to json.loads for type-checker correctness. + session_data = json.loads(str(session_json)) tabs_state = session_data.get('tabs', []) active_tab = session_data.get('active_tab', 0) @@ -502,6 +516,22 @@ def update_tab_name(self, clean: bool) -> None: self.tab_widget.setTabText(i, name) self.tab_widget.setTabToolTip(i, name) + def _toggle_rewrite_highlights(self, checked: bool) -> None: + display_setting.highlight_rewrites = checked + self.refresh_rewrite_highlight() + + def refresh_rewrite_highlight(self) -> None: + """Re-apply or clear rewrite step highlighting based on current setting. + Call after changing the highlight-rewrites setting so the graph updates immediately.""" + self.show_rewrite_highlights_action.setChecked(display_setting.highlight_rewrites) + if not isinstance(self.active_panel, ProofPanel): + return + proof_panel = cast(ProofPanel, self.active_panel) + step_view = proof_panel.step_view + current_row = step_view.currentIndex().row() + if current_row >= 0: + step_view.move_to_step(current_row) + def tab_changed(self, i: int) -> None: if isinstance(self.active_panel, ProofPanel): self.proof_as_rewrite_action.setEnabled(True) @@ -929,8 +959,12 @@ def sfx_on(self, value: bool) -> None: set_settings_value("sound-effects", value, bool, self.settings) def play_sound(self, s: SFXEnum) -> None: - if self.sfx_on: - self.effects[s].play() + if not self.sfx_on: + return + if s not in self.effects: + # Load on first use (or when user enables sound effects). + self.effects[s] = load_sfx(s) + self.effects[s].play() def _toggle_sfx(self) -> None: self.sfx_on = not self.sfx_on diff --git a/zxlive/proof.py b/zxlive/proof.py index 91a1e5e0..7c3b25b5 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import json from typing import TYPE_CHECKING, Any, NamedTuple, Optional, Union, Dict if TYPE_CHECKING: + from .graphscene import GraphScene from .proof_panel import ProofPanel from PySide6.QtCore import (QAbstractItemModel, QAbstractListModel, @@ -12,10 +15,111 @@ QStyle, QStyledItemDelegate, QStyleOptionViewItem, QWidget) -from .common import GraphT +from .common import GraphT, ET from .settings import display_setting +def _apply_edge_only_highlight( + scene: GraphScene, + rewrite_meta: "Rewrite", + g_current: GraphT, + current_verts: set[int], +) -> bool: + """Handle edge-only highlighting (e.g. Add identity).""" + highlight_edge_pairs = getattr(rewrite_meta, "highlight_edge_pairs", None) + if not highlight_edge_pairs: + return False + + edges_only_set: set[ET] = set() + for pair in highlight_edge_pairs: + if isinstance(pair, tuple) and len(pair) == 2: + v1, v2 = int(pair[0]), int(pair[1]) + if v1 in current_verts and v2 in current_verts: + edges_only_set |= set(g_current.edges(v1, v2)) + if not edges_only_set: + return False + + scene.set_rewrite_highlight(set(), edges_only_set) + return True + + +def _apply_match_pair_highlight( + scene: GraphScene, + rewrite_meta: "Rewrite", + g_current: GraphT, + current_verts: set[int], +) -> bool: + """Handle MATCH_DOUBLE-style highlighting from vertex pairs.""" + highlight_match_pairs = getattr(rewrite_meta, "highlight_match_pairs", None) + if not highlight_match_pairs: + return False + + verts_set: set[int] = set() + edges_set: set[ET] = set() + for match in highlight_match_pairs: + if isinstance(match, tuple) and len(match) == 2: + v1, v2 = int(match[0]), int(match[1]) + if v1 not in current_verts or v2 not in current_verts: + continue + verts_set.add(v1) + verts_set.add(v2) + edges_set |= set(g_current.edges(v1, v2)) + if not verts_set and not edges_set: + return False + + scene.set_rewrite_highlight(verts_set, edges_set) + return True + + +def _apply_vertex_based_highlight( + scene: GraphScene, + rewrite_meta: "Rewrite", + g_current: GraphT, + current_verts: set[int], +) -> bool: + """Handle vertex-based highlighting (unfuse, color change, strong comp, etc.).""" + highlight_verts_list = getattr(rewrite_meta, "highlight_verts", None) + if not highlight_verts_list: + return False + + verts_set = {int(v) for v in highlight_verts_list if int(v) in current_verts} + if not verts_set: + return False + + edges_highlight: set[ET] + if len(verts_set) == 2: + s = sorted(verts_set) + edges_highlight = set(g_current.edges(s[0], s[1])) + else: + edges_highlight = set() + for v in verts_set: + for e in g_current.incident_edges(v): + s, t = g_current.edge_st(e) + if s in verts_set and t in verts_set: + edges_highlight.add(e) + + scene.set_rewrite_highlight(verts_set, edges_highlight) + return True + + +def _apply_rewrite_highlight( + scene: GraphScene, + rewrite_meta: "Rewrite", + g_current: GraphT, +) -> None: + """Apply forward-looking rewrite highlight based on rewrite metadata.""" + current_verts = {int(v) for v in g_current.vertices()} + + if _apply_edge_only_highlight(scene, rewrite_meta, g_current, current_verts): + return + if _apply_match_pair_highlight(scene, rewrite_meta, g_current, current_verts): + return + if _apply_vertex_based_highlight(scene, rewrite_meta, g_current, current_verts): + return + + scene.clear_rewrite_highlight() + + class Rewrite(NamedTuple): """A rewrite turns a graph into another graph.""" @@ -23,6 +127,13 @@ class Rewrite(NamedTuple): rule: str # Name of the rule that was applied to get to this step graph: GraphT # New graph after applying the rewrite grouped_rewrites: Optional[list['Rewrite']] = None # Optional field to store the grouped rewrites + # Optional semantic highlight for forward highlighting: + # - highlight_match_pairs: For MATCH_DOUBLE (e.g. Spider Fusion) [(v1, v2), ...]. + # - highlight_verts: Vertex IDs to highlight (unfuse, color change, strong comp, remove id, etc.). + # - highlight_edge_pairs: Edge-only (e.g. Add identity) [(v1, v2), ...] - highlight edges between pairs only. + highlight_match_pairs: Optional[list[tuple[int, int]]] = None + highlight_verts: Optional[list[int]] = None + highlight_edge_pairs: Optional[list[tuple[int, int]]] = None def to_dict(self) -> Dict[str, Any]: """Serializes the rewrite to Python dictionary.""" @@ -30,7 +141,10 @@ def to_dict(self) -> Dict[str, Any]: "display_name": self.display_name, "rule": self.rule, "graph": self.graph.to_dict(), - "grouped_rewrites": [r.to_dict() for r in self.grouped_rewrites] if self.grouped_rewrites else None + "grouped_rewrites": [r.to_dict() for r in self.grouped_rewrites] if self.grouped_rewrites else None, + "highlight_match_pairs": self.highlight_match_pairs, + "highlight_verts": self.highlight_verts, + "highlight_edge_pairs": self.highlight_edge_pairs, } def to_json(self) -> str: @@ -49,11 +163,23 @@ def from_json(json_str: Union[str, Dict[str, Any]]) -> "Rewrite": assert isinstance(graph, GraphT) graph.set_auto_simplify(False) + pairs = d.get("highlight_match_pairs") + if pairs is not None: + pairs = [tuple(p) for p in pairs] + edge_pairs = d.get("highlight_edge_pairs") + if edge_pairs is not None: + edge_pairs = [tuple(p) for p in edge_pairs] + # Backward compat: old proofs may have highlight_unfuse_verts + highlight_verts = d.get("highlight_verts") or d.get("highlight_unfuse_verts") + return Rewrite( display_name=d.get("display_name", d["rule"]), # Old proofs may not have display names rule=d["rule"], graph=graph, - grouped_rewrites=[Rewrite.from_json(r) for r in grouped_rewrites] if grouped_rewrites else None + grouped_rewrites=[Rewrite.from_json(r) for r in grouped_rewrites] if grouped_rewrites else None, + highlight_match_pairs=pairs, + highlight_verts=highlight_verts, + highlight_edge_pairs=edge_pairs, ) @@ -77,7 +203,15 @@ def set_graph(self, index: int, graph: GraphT) -> None: self.initial_graph = graph else: old_step = self.steps[index - 1] - new_step = Rewrite(old_step.display_name, old_step.rule, graph) + new_step = Rewrite( + old_step.display_name, + old_step.rule, + graph, + old_step.grouped_rewrites, + old_step.highlight_match_pairs, + old_step.highlight_verts, + old_step.highlight_edge_pairs, + ) self.steps[index - 1] = new_step def graphs(self) -> list[GraphT]: @@ -159,7 +293,11 @@ def rename_step(self, index: int, name: str) -> None: # Must create a new Rewrite object instead of modifying current object # since Rewrite inherits NamedTuple and is hence immutable - self.steps[index] = Rewrite(name, old_step.rule, old_step.graph, old_step.grouped_rewrites) + self.steps[index] = Rewrite( + name, old_step.rule, old_step.graph, old_step.grouped_rewrites, + old_step.highlight_match_pairs, old_step.highlight_verts, + old_step.highlight_edge_pairs, + ) # Rerender the proof step otherwise it will display the old name until # the cursor moves @@ -172,7 +310,8 @@ def group_steps(self, start_index: int, end_index: int) -> None: "Grouped Steps: " + " 🡒 ".join(self.steps[i].display_name for i in range(start_index, end_index + 1)), "Grouped", self.get_graph(end_index + 1), - self.steps[start_index:end_index + 1] + self.steps[start_index:end_index + 1], + None, None, None, ) for _ in range(end_index - start_index + 1): self.pop_rewrite(start_index)[0] @@ -280,8 +419,26 @@ def move_to_step(self, index: int) -> None: self.setCurrentIndex(idx) self.selectionModel().blockSignals(False) self.update(idx) - g = self.model().get_graph(index) - self.graph_view.set_graph(g) + g_current = self.model().get_graph(index) + self.graph_view.set_graph(g_current) + + # Highlight the differences between this step and the *next* one. + scene = self.graph_view.graph_scene + num_steps = len(self.model().steps) + + # If highlighting is disabled in the settings, always clear any + # existing rewrite highlight and return. + if not display_setting.highlight_rewrites: + scene.clear_rewrite_highlight() + return + + # Last proof step: no "next" transition to highlight (forward-looking). + if index >= num_steps: + scene.clear_rewrite_highlight() + return + # There is a rewrite taking graph index -> index + 1. + rewrite_meta = self.model().steps[index] + _apply_rewrite_highlight(scene, rewrite_meta, g_current) def show_context_menu(self, position: QPoint) -> None: selected_indexes = self.selectedIndexes() @@ -367,9 +524,8 @@ class ProofStepItemDelegate(QStyledItemDelegate): circle_radius_selected = 6 circle_outline_width = 3 - # TODO: Fix code complexity - # noqa: complexipy - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> None: # noqa: PLR0912 + # TODO: Fix code complexity # noqa: complexipy + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> None: # noqa: PLR0912 # pylint: disable=too-many-branches painter.save() # Draw background painter.setPen(Qt.GlobalColor.transparent) diff --git a/zxlive/proof_panel.py b/zxlive/proof_panel.py index 23b361b1..d46e0551 100644 --- a/zxlive/proof_panel.py +++ b/zxlive/proof_panel.py @@ -11,8 +11,8 @@ import pyzx from pyzx.graph.jsonparser import string_to_phase -from pyzx.utils import (EdgeType, VertexType, FractionLike, get_w_partner, get_z_box_label, - set_z_box_label, vertex_is_z_like) +from pyzx.utils import (EdgeType, VertexType, FractionLike, get_w_io, get_w_partner, + get_z_box_label, set_z_box_label, vertex_is_w, vertex_is_z_like) from . import animations as anims from .base_panel import BasePanel, ToolbarSection @@ -184,38 +184,52 @@ def _vertex_dragged(self, state: DragState, v: VT, w: VT) -> None: anims.back_to_default(self.graph_scene.vertex_map[w]) def _vertex_dropped_onto(self, v: VT, w: VT) -> None: - g = copy.deepcopy(self.graph) + base_g = self.graph_scene.g + g = copy.deepcopy(base_g) if len(list(self.graph.edges(v, w))) == 1 and self.graph.edge_type(self.graph.edge(v, w)) == EdgeType.HADAMARD: pyzx.rewrite_rules.color_change(g, w) if pyzx.rewrite_rules.check_fuse(g, v, w): pyzx.rewrite_rules.fuse(g, w, v) anim = anims.fuse(self.graph_scene.vertex_map[v], self.graph_scene.vertex_map[w]) - cmd = AddRewriteStep(self.graph_view, g, self.step_view, "Fuse spiders") + # For W fusion, highlight both W nodes (input+output each); proof filters to existing verts. + if vertex_is_w(base_g.type(v)) and vertex_is_w(base_g.type(w)): + v_in, v_out = get_w_io(base_g, v) + w_in, w_out = get_w_io(base_g, w) + cmd = AddRewriteStep( + self.graph_view, g, self.step_view, "Fuse spiders", + highlight_verts=[v_in, v_out, w_in, w_out], + ) + else: + cmd = AddRewriteStep( + self.graph_view, g, self.step_view, "Fuse spiders", + highlight_match_pairs=[(v, w)], + ) self.play_sound_signal.emit(SFXEnum.THATS_SPIDER_FUSION) self.undo_stack.push(cmd, anim_before=anim) elif pyzx.rewrite_rules.check_copy(g, v): pyzx.rewrite_rules.copy(g, v) - # copy_match = pyzx.hrules.match_copy(g, lambda x: x in (v, w)) - # etab, rem_verts, rem_edges, check_isolated_vertices = pyzx.hrules.apply_copy(g, copy_match) - # g.add_edge_table(etab) - # g.remove_edges(rem_edges) - # g.remove_vertices(rem_verts) anim = anims.strong_comp(self.graph, g, w, self.graph_scene) - cmd = AddRewriteStep(self.graph_view, g, self.step_view, "Copy spider through other spider") + cmd = AddRewriteStep( + self.graph_view, g, self.step_view, "Copy spider through other spider", + highlight_verts=[v, w], + ) self.undo_stack.push(cmd, anim_after=anim) elif pyzx.rewrite_rules.check_pauli(g, w, v): # Second parameter is the Pauli - # Check if we can push a Pauli spider through the other vertex pyzx.rewrite_rules.pauli_push(g, w, v) - # Determine which vertex is the target (the one being pushed through) - # The match is (pauli_vertex, target_vertex) target = w anim = anims.strong_comp(self.graph, g, target, self.graph_scene) - cmd = AddRewriteStep(self.graph_view, g, self.step_view, "Push Pauli") + cmd = AddRewriteStep( + self.graph_view, g, self.step_view, "Push Pauli", + highlight_verts=[v, w], + ) self.undo_stack.push(cmd, anim_after=anim) elif pyzx.rewrite_rules.check_bialgebra(g, v, w): pyzx.rewrite_rules.bialgebra(g, w, v) anim = anims.strong_comp(self.graph, g, w, self.graph_scene) - cmd = AddRewriteStep(self.graph_view, g, self.step_view, "Strong complementarity") + cmd = AddRewriteStep( + self.graph_view, g, self.step_view, "Strong complementarity", + highlight_verts=[v, w], + ) self.play_sound_signal.emit(SFXEnum.BOOM_BOOM_BOOM) self.undo_stack.push(cmd, anim_after=anim) else: @@ -256,7 +270,10 @@ def _magic_hopf(self, trace: WandTrace) -> bool: new_g.remove_edge(edges[0]) # TODO: Add animation for Hopf # anim = anims.hopf(edges, self.graph_scene) - cmd = AddRewriteStep(self.graph_view, new_g, self.step_view, "Remove parallel edges") + cmd = AddRewriteStep( + self.graph_view, new_g, self.step_view, "Remove parallel edges", + highlight_edge_pairs=[(source, target)], + ) self.undo_stack.push(cmd) return True return False @@ -287,7 +304,10 @@ def _magic_identity(self, trace: WandTrace) -> bool: new_g.remove_edge(item.e) anim = anims.add_id(v, self.graph_scene) - cmd = AddRewriteStep(self.graph_view, new_g, self.step_view, "Add identity") + cmd = AddRewriteStep( + self.graph_view, new_g, self.step_view, "Add identity", + highlight_edge_pairs=[(s, t)], + ) self.undo_stack.push(cmd, anim_after=anim) return True @@ -304,7 +324,8 @@ def cross(a: QPointF, b: QPointF) -> float: if self.graph.type(vertex) not in (VertexType.Z, VertexType.X, VertexType.Z_BOX, VertexType.W_OUTPUT): return False - if not trace.shift and pyzx.rewrite_rules.check_remove_id(self.graph, vertex): + is_identity = pyzx.rewrite_rules.check_remove_id(self.graph, vertex) + if not trace.shift and is_identity: self._remove_id(vertex) return True @@ -361,7 +382,10 @@ def _remove_id(self, v: VT) -> None: new_g = copy.deepcopy(self.graph) pyzx.rewrite_rules.remove_id(new_g, v) anim = anims.remove_id(self.graph_scene.vertex_map[v]) - cmd = AddRewriteStep(self.graph_view, new_g, self.step_view, "Remove identity") + cmd = AddRewriteStep( + self.graph_view, new_g, self.step_view, "Remove identity", + highlight_verts=[v], + ) self.undo_stack.push(cmd, anim_before=anim) s = random.choice([ @@ -393,11 +417,16 @@ def _reassign_edges_to_left_vertex(self, v: VT, new_g: GraphT, left_vert: VT, new_g.add_edge((neighbor, left_vert), self.graph.edge_type(edge)) new_g.remove_edge(edge) - def _finalize_unfuse(self, v: VT, new_g: GraphT) -> None: - """Helper method to apply animation and push the unfuse command to the undo stack. - """ + def _finalize_unfuse( + self, v: VT, new_g: GraphT, extra_vertices: Optional[list[VT]] = None + ) -> None: + """Apply animation and push the unfuse command to the undo stack.""" anim = anims.unfuse(self.graph, new_g, v, self.graph_scene) - cmd = AddRewriteStep(self.graph_view, new_g, self.step_view, "unfuse") + verts = [v] + (extra_vertices or []) + cmd = AddRewriteStep( + self.graph_view, new_g, self.step_view, "unfuse", + highlight_verts=verts, + ) self.undo_stack.push(cmd, anim_after=anim) def _unfuse_w(self, v: VT, left_edge_items: list[EItem], mouse_dir: QPointF) -> None: @@ -432,7 +461,7 @@ def _unfuse_w(self, v: VT, left_edge_items: list[EItem], mouse_dir: QPointF) -> # TODO: preserve the edge curve here once it is supported (see https://github.com/zxcalc/zxlive/issues/270) self._reassign_edges_to_left_vertex(v, new_g, left_vert, left_edge_items, skip_edge_type=EdgeType.W_IO) - self._finalize_unfuse(v, new_g) + self._finalize_unfuse(v, new_g, extra_vertices=[left_vert, left_vert_i]) def _unfuse(self, v: VT, left_edge_items: list[EItem], right_edge_items: list[EItem], mouse_dir: QPointF, phase: Union[FractionLike, complex]) -> None: def snap_vector(v: QVector2D) -> None: @@ -492,7 +521,7 @@ def compute_avg_vector(pos: QPointF, neighbors: list[EItem]) -> QVector2D: new_g.set_phase(first, old_phase - phase) new_g.set_phase(second, phase) - self._finalize_unfuse(v, new_g) + self._finalize_unfuse(v, new_g, extra_vertices=[left_vert]) def _vert_double_clicked(self, v: VT) -> None: ty = self.graph.type(v) @@ -501,7 +530,10 @@ def _vert_double_clicked(self, v: VT) -> None: if ty in (VertexType.Z, VertexType.X): new_g = copy.deepcopy(self.graph) pyzx.rewrite_rules.color_change(new_g, v) - cmd = AddRewriteStep(self.graph_view, new_g, self.step_view, "Color change spider") + cmd = AddRewriteStep( + self.graph_view, new_g, self.step_view, "Color change spider", + highlight_verts=[v], + ) self.undo_stack.push(cmd) return if ty == VertexType.H_BOX: @@ -509,7 +541,10 @@ def _vert_double_clicked(self, v: VT) -> None: if not pyzx.rewrite_rules.check_hadamard(new_g, v): return pyzx.rewrite_rules.replace_hadamard(new_g, v) - cmd = AddRewriteStep(self.graph_view, new_g, self.step_view, "Turn Hadamard into edge") + cmd = AddRewriteStep( + self.graph_view, new_g, self.step_view, "Turn Hadamard into edge", + highlight_verts=[v], + ) self.undo_stack.push(cmd) return if ty == VertexType.DUMMY: @@ -523,8 +558,12 @@ def _edge_double_clicked(self, e: ET) -> None: """When an edge is double clicked, we change it to an H-box if it is a Hadamard edge.""" new_g = copy.deepcopy(self.graph) if new_g.edge_type(e) == EdgeType.HADAMARD: + s, t = new_g.edge_st(e) pyzx.rewrite_rules.had_edge_to_hbox(new_g, *new_g.edge_st(e)) - cmd = AddRewriteStep(self.graph_view, new_g, self.step_view, "Turn edge into Hadamard") + cmd = AddRewriteStep( + self.graph_view, new_g, self.step_view, "Turn edge into Hadamard", + highlight_verts=[s, t], + ) self.undo_stack.push(cmd) def _dummy_node_mode_clicked(self) -> None: diff --git a/zxlive/rewrite_action.py b/zxlive/rewrite_action.py index 6a9312ba..3371f70c 100644 --- a/zxlive/rewrite_action.py +++ b/zxlive/rewrite_action.py @@ -27,11 +27,108 @@ from .graphscene import GraphScene from .graphview import GraphView from .custom_rule import CustomRule +from pyzx.utils import get_w_io, vertex_is_w if TYPE_CHECKING: from .proof_panel import ProofPanel -# operations = copy.deepcopy(pyzx.editor.operations) +MatchItem = Union[VT, tuple[VT, VT], list[VT]] + + +def _find_matches( + rule: Rewrite, + match_type: MatchType, + g: GraphT, + verts: list[VT], + edges: list[ET], +) -> list[MatchItem]: + """Find matches for the rule in the graph based on selection.""" + if isinstance(rule, CustomRule): + return [rule.is_match(g, verts)] # type: ignore[return-value] + if match_type == MATCH_SINGLE: + rule_sv = cast(RewriteSingleVertex, rule) + return [v for v in verts if rule_sv.is_match(g, v)] + if match_type == MATCH_DOUBLE: + rule_dv = cast(RewriteDoubleVertex, rule) + return [ + g.edge_st(e) for e in edges + if g.edge_st(e)[0] != g.edge_st(e)[1] + and rule_dv.is_match(g, *g.edge_st(e)) + ] + if match_type == MATCH_COMPOUND: + return [list(g.vertices())] if len(verts) == 0 else [verts.copy()] # type: ignore[return-value] + return [] + + +def _apply_single_match( + rule: Rewrite, + match_type: MatchType, + g: GraphT, + m: MatchItem, +) -> bool: + """Apply one match; returns True if the rule was applied.""" + if match_type == MATCH_DOUBLE: + rule_dv = cast(RewriteDoubleVertex, rule) + v1, v2 = cast(tuple[VT, VT], m) + return rule_dv.apply(g, v1, v2) + if match_type == MATCH_SINGLE: + rule_sv = cast(RewriteSingleVertex, rule) + return rule_sv.apply(g, cast(VT, m)) + rule_sg = cast(RewriteSimpGraph, rule) + return rule_sg.apply(g, cast(list[VT], m)) + + +def _highlight_verts_compound(matches_list: list[MatchItem]) -> Optional[list[VT]]: + """Compute highlight vertices for MATCH_COMPOUND rules.""" + all_v: set[VT] = set() + for match in matches_list: + if isinstance(match, list): + all_v.update(match) + return list(all_v) if all_v else None + + +def _highlight_verts_bialgebra(matches_list: list[MatchItem]) -> Optional[list[VT]]: + """Compute highlight vertices for the bialgebra rule.""" + involved: set[VT] = set() + for match in matches_list: + if isinstance(match, tuple) and len(match) == 2: + v1, v2 = cast(tuple[VT, VT], match) + involved.add(v1) + involved.add(v2) + return list(involved) if involved else None + + +def _highlight_verts_generic(matches_list: list[MatchItem]) -> Optional[list[VT]]: + """Compute generic highlight vertices for MATCH_SINGLE or MATCH_DOUBLE.""" + vs: set[VT] = set() + for match in matches_list: + if isinstance(match, int): + vs.add(cast(VT, match)) + elif isinstance(match, tuple) and len(match) == 2: + v1, v2 = cast(tuple[VT, VT], match) + vs.add(v1) + vs.add(v2) + elif isinstance(match, list): + vs.update(cast(list[VT], match)) + return list(vs) if vs else None + + +def _compute_highlight_verts( + matches_list: list[MatchItem], + match_type: MatchType, + name: str, +) -> Optional[list[VT]]: + """Compute highlight_verts from matches_list.""" + if not matches_list: + return None + + if match_type == MATCH_COMPOUND: + return _highlight_verts_compound(matches_list) + + if match_type == MATCH_DOUBLE and name == rules_basic["bialgebra"]["text"]: + return _highlight_verts_bialgebra(matches_list) + + return _highlight_verts_generic(matches_list) @dataclass @@ -77,9 +174,8 @@ def from_rewrite_data(cls, d: RewriteData) -> RewriteAction: file_path=d.get('file_path', None), ) - # TODO: Fix code complexity - # noqa: complexipy - def do_rewrite(self, panel: ProofPanel) -> None: # noqa: PLR0912 + # TODO: Fix code complexity # noqa: complexipy, D401 + def do_rewrite(self, panel: ProofPanel) -> None: # noqa: PLR0912 # pylint: disable=too-many-locals if not self.enabled: return @@ -92,62 +188,59 @@ def do_rewrite(self, panel: ProofPanel) -> None: # noqa: PLR0912 self.unfusion_action.start_unfusion(verts[0]) return - g = copy.deepcopy(panel.graph_scene.g) + base_g = panel.graph_scene.g + g = copy.deepcopy(base_g) verts, edges = panel.parse_selection() rem_verts_list: list[VT] = [] - matches_list: list[VT | tuple[VT, VT] | list[VT]] = [] + matches_list: list[MatchItem] = [] + is_fuse_rule = ( + self.name == rules_basic["fuse_simp"]["text"] + and self.match_type == MATCH_DOUBLE + ) + highlight_match_pairs: list[tuple[VT, VT]] | None = None + fuse_w_verts: Optional[list[VT]] = None + while True: - matches: list[VT | tuple[VT, VT] | list[VT]] = [] - if isinstance(self.rule, CustomRule): - matches = [self.rule.is_match(g, verts)] # type: ignore - elif self.match_type == MATCH_SINGLE: - rule_sv = cast(RewriteSingleVertex, self.rule) - matches = [v for v in verts if rule_sv.is_match(g, v)] - elif self.match_type == MATCH_DOUBLE: - rule_dv = cast(RewriteDoubleVertex, self.rule) - matches = [g.edge_st(e) for e in edges - if g.edge_st(e)[0] != g.edge_st(e)[1] - and rule_dv.is_match(g, *g.edge_st(e))] - elif self.match_type == MATCH_COMPOUND: # We don't necessarily have a matcher in this case - if len(verts) == 0: - matches = [list(g.vertices())] # type: ignore - else: - matches = [verts.copy()] # type: ignore + matches = _find_matches(self.rule, self.match_type, g, verts, edges) matches_list.extend(matches) if not matches: break try: applied = False for m in matches: - if self.match_type == MATCH_DOUBLE: - rule_dv = cast(RewriteDoubleVertex, self.rule) + if is_fuse_rule and self.match_type == MATCH_DOUBLE: v1, v2 = cast(tuple[VT, VT], m) - if rule_dv.apply(g, v1, v2): - applied = True - elif self.match_type == MATCH_SINGLE: - rule_sv = cast(RewriteSingleVertex, self.rule) - if rule_sv.apply(g, cast(VT, m)): - applied = True - else: - rule_sg = cast(RewriteSimpGraph, self.rule) - if rule_sg.apply(g, cast(list[VT], m)): - applied = True - # g, rem_verts = self.apply_rewrite(g, matches) - # rem_verts_list.extend(rem_verts) + if fuse_w_verts is None and vertex_is_w(g.type(v1)) and vertex_is_w(g.type(v2)): + fuse_w_verts = list(get_w_io(g, v1)) + list(get_w_io(g, v2)) + if highlight_match_pairs is None: + highlight_match_pairs = [] + highlight_match_pairs.append((v1, v2)) + if _apply_single_match(self.rule, self.match_type, g, m): + applied = True except Exception as ex: show_error_msg('Error while applying rewrite rule', str(ex)) return if not self.repeat_rule_application or not applied: break - cmd = AddRewriteStep(panel.graph_view, g, panel.step_view, self.name) + highlight_verts = _compute_highlight_verts( + matches_list, self.match_type, self.name + ) + + cmd = AddRewriteStep( + panel.graph_view, + g, + panel.step_view, + self.name, + highlight_match_pairs=None if fuse_w_verts is not None else (highlight_match_pairs if is_fuse_rule else None), + highlight_verts=fuse_w_verts if fuse_w_verts is not None else highlight_verts, + ) anim_before, anim_after = make_animation(self, panel, g, matches_list, rem_verts_list) panel.undo_stack.push(cmd, anim_before=anim_before, anim_after=anim_after) - # TODO: Fix code complexity - # noqa: complexipy - def update_active(self, g: GraphT, verts: list[VT], edges: list[ET]) -> None: # noqa: PLR0912 + # TODO: Fix code complexity # noqa: complexipy, D401 + def update_active(self, g: GraphT, verts: list[VT], edges: list[ET]) -> None: # noqa: PLR0912 # pylint: disable=too-many-branches if self.copy_first: g = copy.deepcopy(g) if self.match_type == MATCH_SINGLE: diff --git a/zxlive/rewrite_data.py b/zxlive/rewrite_data.py index 00f822b0..cbf53bc5 100644 --- a/zxlive/rewrite_data.py +++ b/zxlive/rewrite_data.py @@ -49,7 +49,7 @@ def read_custom_rules() -> list[RewriteData]: zxr_file = os.path.join(root, file) with open(zxr_file, "r") as f: rule = CustomRule.from_json(f.read()).to_rewrite_data() - rule['file_path'] = zxr_file + rule["file_path"] = zxr_file custom_rules.append(rule) return custom_rules diff --git a/zxlive/settings.py b/zxlive/settings.py index 7df6cac9..6f9a41a4 100644 --- a/zxlive/settings.py +++ b/zxlive/settings.py @@ -33,6 +33,8 @@ class ColorScheme(TypedDict): z_pauli_web: QColor x_pauli_web: QColor y_pauli_web: QColor + rewrite_highlight_vertex: QColor + rewrite_highlight_edge: QColor general_defaults: dict[str, str | QTabWidget.TabPosition | int | bool] = { @@ -45,6 +47,7 @@ class ColorScheme(TypedDict): "input-circuit-format": 'openqasm', "previews-show": True, "sparkle-mode": True, + "highlight-rewrites": True, 'sound-effects': False, "matrix/precision": 4, "dark-mode": "system", @@ -134,6 +137,8 @@ class ColorScheme(TypedDict): "z_pauli_web": QColor("#ccffcc"), "x_pauli_web": QColor("#ff8888"), "y_pauli_web": QColor("#6688ff"), + "rewrite_highlight_vertex": QColor("#E65100"), + "rewrite_highlight_edge": QColor("#FF9800"), } classic_red_green: ColorScheme = { @@ -147,6 +152,8 @@ class ColorScheme(TypedDict): "z_pauli_web": QColor("#00ff00"), "x_pauli_web": QColor("#ff0d00"), "y_pauli_web": QColor("#0000ff"), + "rewrite_highlight_vertex": QColor("#E65100"), + "rewrite_highlight_edge": QColor("#FF9800"), } white_gray: ColorScheme = { @@ -159,6 +166,8 @@ class ColorScheme(TypedDict): "x_spider_pressed": QColor("#a0a0a0"), "hadamard": QColor("#ffffff"), "hadamard_pressed": QColor("#dddddd"), + "rewrite_highlight_vertex": QColor("#1976D2"), + "rewrite_highlight_edge": QColor("#42A5F5"), } gidney: ColorScheme = { @@ -169,6 +178,8 @@ class ColorScheme(TypedDict): "z_spider_pressed": QColor("#222222"), "x_spider": QColor("#ffffff"), "x_spider_pressed": QColor("#dddddd"), + "rewrite_highlight_vertex": QColor("#1565C0"), + "rewrite_highlight_edge": QColor("#42A5F5"), } color_schemes = { @@ -243,6 +254,15 @@ def previews_show(self) -> bool: def previews_show(self, value: bool) -> None: settings.setValue("previews-show", value) + @property + def highlight_rewrites(self) -> bool: + """Whether proof-step rewrite highlighting is enabled.""" + return get_settings_value("highlight-rewrites", bool) + + @highlight_rewrites.setter + def highlight_rewrites(self, value: bool) -> None: + settings.setValue("highlight-rewrites", value) + @property def dark_mode(self) -> bool: dark_mode_setting = str(settings.value("dark-mode", "system")) @@ -283,11 +303,17 @@ def adjust_for_dark(color: QColor) -> QColor: lightness = int(lightness * 0.6) saturation = int(saturation * 0.4) return QColor.fromHsl(hue, saturation, lightness, alpha) + base: dict[str, QColor] = {k: v for k, v in self.colors.items() if isinstance(v, QColor)} + # Always use fixed highlight colors for readability in both light and dark mode + base["rewrite_highlight_vertex"] = QColor("#FFB74D") + base["rewrite_highlight_edge"] = QColor("#FFC107") if self.dark_mode: for k in base: if k in ("outline", "edge", "boundary", "boundary_pressed", "w_input", "w_input_pressed", "w_output", "w_output_pressed"): base[k] = QColor("#dbdbdb") + elif k in ("rewrite_highlight_vertex", "rewrite_highlight_edge"): + pass # Already set above else: base[k] = adjust_for_dark(base[k]) # else: do not adjust for light mode diff --git a/zxlive/settings_dialog.py b/zxlive/settings_dialog.py index 089f448a..1e88d9e7 100644 --- a/zxlive/settings_dialog.py +++ b/zxlive/settings_dialog.py @@ -118,6 +118,7 @@ class SettingsData(TypedDict): {"id": "auto-save", "label": "Auto Save", "type": FormInputType.Bool}, {"id": "dark-mode", "label": "Theme", "type": FormInputType.Combo, "data": dark_mode_options}, {"id": "sparkle-mode", "label": "Sparkle Mode", "type": FormInputType.Bool}, + {"id": "highlight-rewrites", "label": "Highlight rewrite steps", "type": FormInputType.Bool}, {"id": "previews-show", "label": "Show rewrite previews", "type": FormInputType.Bool}, {"id": "sound-effects", "label": "Sound Effects", "type": FormInputType.Bool}, {"id": "color-scheme", "label": "Color scheme", "type": FormInputType.Combo, "data": color_scheme_data}, @@ -398,6 +399,7 @@ def apply_global_settings(self) -> None: if isinstance(app, QApplication): app.setFont(display_setting.font) self.main_window.update_font() + self.main_window.refresh_rewrite_highlight() def cancel(self) -> None: self.reject() diff --git a/zxlive/sfx.py b/zxlive/sfx.py index b79c8279..f7402c93 100644 --- a/zxlive/sfx.py +++ b/zxlive/sfx.py @@ -1,9 +1,12 @@ from enum import Enum from PySide6.QtCore import QUrl -from PySide6.QtMultimedia import QSoundEffect +from typing import TYPE_CHECKING from .common import get_data +if TYPE_CHECKING: + from PySide6.QtMultimedia import QSoundEffect + class SFXEnum(Enum): BOOM_BOOM_BOOM = "boom-boom-boom.wav" @@ -17,8 +20,12 @@ class SFXEnum(Enum): WELCOME_EVERYBODY = "welcome-everybody.wav" -def load_sfx(e: SFXEnum) -> QSoundEffect: +def load_sfx(e: SFXEnum) -> "QSoundEffect": """Load a sound effect from a file.""" + # Import QtMultimedia lazily to avoid importing / initializing multimedia + # backends during module import (important for headless CI). + from PySide6.QtMultimedia import QSoundEffect + effect = QSoundEffect() fullpath = get_data("sfx/" + e.value) url = QUrl.fromLocalFile(fullpath) diff --git a/zxlive/unfusion_rewrite.py b/zxlive/unfusion_rewrite.py index 003250e9..69d06cfe 100644 --- a/zxlive/unfusion_rewrite.py +++ b/zxlive/unfusion_rewrite.py @@ -60,7 +60,8 @@ def __init__(self, proof_panel: ProofPanel) -> None: def can_unfuse(self, vertex: VT) -> bool: """Check if a vertex can be unfused.""" graph = self.proof_panel.graph_scene.g - return bool(graph.type(vertex) != VertexType.BOUNDARY) + return (graph.type(vertex) != VertexType.BOUNDARY and + len(list(graph.incident_edges(vertex))) >= 2) def start_unfusion(self, vertex: VT) -> bool: """Start the unfusion process for a vertex.""" diff --git a/zxlive/vitem.py b/zxlive/vitem.py index 75b47738..0bbbfbcf 100644 --- a/zxlive/vitem.py +++ b/zxlive/vitem.py @@ -39,6 +39,25 @@ BLACK = "#000000" +_VITEM_COLOR_MAP = { + VertexType.Z: "z_spider", + VertexType.Z_BOX: "z_spider", + VertexType.X: "x_spider", + VertexType.H_BOX: "hadamard", + VertexType.W_INPUT: "w_input", + VertexType.W_OUTPUT: "w_output", + VertexType.DUMMY: "dummy", +} +_VITEM_PRESSED_COLOR_MAP = { + VertexType.Z: "z_spider_pressed", + VertexType.Z_BOX: "z_spider_pressed", + VertexType.X: "x_spider_pressed", + VertexType.H_BOX: "hadamard_pressed", + VertexType.W_INPUT: "w_input_pressed", + VertexType.W_OUTPUT: "w_output_pressed", + VertexType.DUMMY: "dummy_pressed", +} + # Z values for different items. We use those to make sure that edges # are drawn below vertices and selected vertices above unselected # vertices during movement. Phase items are drawn on the very top. @@ -132,69 +151,62 @@ def is_dragging(self) -> bool: def is_animated(self) -> bool: return len(self.active_animations) > 0 - # TODO: Fix code complexity - # noqa: complexipy - def refresh(self) -> None: - """Call this method whenever a vertex moves or its data changes""" - self.update_shape() - color_map = { - VertexType.Z: "z_spider", - VertexType.Z_BOX: "z_spider", - VertexType.X: "x_spider", - VertexType.H_BOX: "hadamard", - VertexType.W_INPUT: "w_input", - VertexType.W_OUTPUT: "w_output", - VertexType.DUMMY: "dummy", - } - pressed_color_map = { - VertexType.Z: "z_spider_pressed", - VertexType.Z_BOX: "z_spider_pressed", - VertexType.X: "x_spider_pressed", - VertexType.H_BOX: "hadamard_pressed", - VertexType.W_INPUT: "w_input_pressed", - VertexType.W_OUTPUT: "w_output_pressed", - VertexType.DUMMY: "dummy_pressed", - } + def _base_brush_pen_for_refresh(self) -> tuple[QBrush, QPen]: + """Brush and pen before rewrite-step highlight overlay.""" pen = QPen() if not self.isSelected(): - color_key = color_map.get(self.ty, "boundary") + color_key = _VITEM_COLOR_MAP.get(self.ty, "boundary") brush = QBrush(display_setting.effective_colors[color_key]) # type: ignore # https://github.com/python/mypy/issues/7178 pen.setWidthF(3) pen.setColor(display_setting.effective_colors["outline"]) if self.ty == VertexType.DUMMY: pen.setColor(display_setting.effective_colors["dummy"]) + return brush, pen + + color_key = _VITEM_PRESSED_COLOR_MAP.get(self.ty, "boundary_pressed") + brush = QBrush(display_setting.effective_colors[color_key]) # type: ignore # https://github.com/python/mypy/issues/7178 + brush.setStyle(Qt.BrushStyle.Dense1Pattern) + pen.setWidthF(5) + if display_setting.dark_mode: + pen.setColor(QColor("#dbdbdb")) else: - color_key = pressed_color_map.get(self.ty, "boundary_pressed") - brush = QBrush(display_setting.effective_colors[color_key]) # type: ignore # https://github.com/python/mypy/issues/7178 - brush.setStyle(Qt.BrushStyle.Dense1Pattern) - pen.setWidthF(5) - # Use a light outline in dark mode, otherwise use the pressed color - if display_setting.dark_mode: - pen.setColor(QColor("#dbdbdb")) - else: - pen.setColor(display_setting.effective_colors["boundary_pressed"]) - if self.ty == VertexType.DUMMY: - pen.setColor(display_setting.effective_colors["dummy_pressed"]) - self.prepareGeometryChange() - self.setBrush(brush) - self.setPen(pen) + pen.setColor(display_setting.effective_colors["boundary_pressed"]) + if self.ty == VertexType.DUMMY: + pen.setColor(display_setting.effective_colors["dummy_pressed"]) + return brush, pen - # Render dummy node text (plain or LaTeX) + def _apply_rewrite_highlight_pen(self, pen: QPen) -> None: + """Thicken and recolor pen when this vertex is in rewrite highlight.""" + pen.setWidthF(pen.widthF() + 2.0) + pen.setColor(display_setting.effective_colors["rewrite_highlight_vertex"]) + + def _refresh_dummy_phase_and_edges(self) -> None: + """Update dummy label, phase item, paired W output, and incident edges.""" if self.ty == VertexType.DUMMY: self._update_dummy_display(self.g.vdata(self.v, 'text', '')) else: self.remove_dummy_label() - if self.phase_item: self.phase_item.refresh() if self.ty == VertexType.W_INPUT: w_out = get_w_partner_vitem(self) if w_out: w_out.refresh() - for e_item in self.adj_items: e_item.refresh() + # TODO: Fix code complexity + def refresh(self) -> None: + """Call this method whenever a vertex moves or its data changes""" + self.update_shape() + brush, pen = self._base_brush_pen_for_refresh() + if self.graph_scene.is_vertex_highlighted(self.v): + self._apply_rewrite_highlight_pen(pen) + self.prepareGeometryChange() + self.setBrush(brush) + self.setPen(pen) + self._refresh_dummy_phase_and_edges() + def _make_shape_path(self) -> QPainterPath: """Helper to create the path for both drawing and hit-testing.""" path = QPainterPath() @@ -296,91 +308,105 @@ def mousePressEvent(self, e: QGraphicsSceneMouseEvent) -> None: return self._old_pos = self.pos() - # TODO: Fix code complexity - # noqa: complexipy - def mouseMoveEvent(self, e: QGraphicsSceneMouseEvent) -> None: # noqa: PLR0912 - super().mouseMoveEvent(e) - if self.is_animated: - e.ignore() - return - scene = self.scene() - if TYPE_CHECKING: - assert isinstance(scene, GraphScene) - if self.is_dragging and self.ty == VertexType.W_OUTPUT: + def _mouse_move_drag_w_vertex(self, e: QGraphicsSceneMouseEvent) -> bool: + """Move coupled W input/output during drag. Returns True if event handling should stop.""" + if not self.is_dragging: + return False + if self.ty == VertexType.W_OUTPUT: w_in = get_w_partner_vitem(self) assert w_in is not None if self._last_pos is None: self._last_pos = self.pos() w_in.setPos(w_in.pos() + (self.pos() - self._last_pos)) self._last_pos = self.pos() - elif self.is_dragging and self.ty == VertexType.W_INPUT: + return False + if self.ty == VertexType.W_INPUT: w_out = get_w_partner_vitem(self) if w_out is None: e.ignore() - return + return True w_out.set_vitem_rotation() + return False + + def _mouse_move_drag_hover_onto(self, scene: GraphScene) -> None: + """Emit vertex_dragged when dragging over another vertex while single-selected.""" + reset = True + for it in scene.items(): + if not it.sceneBoundingRect().intersects(self.sceneBoundingRect()): + continue + if isinstance(it, VItem) and vertex_is_w(self.ty) and get_w_partner(self.g, self.v) == it.v: + continue + if it == self._dragged_on: + reset = False + elif isinstance(it, VItem) and it != self: + scene.vertex_dragged.emit(DragState.Onto, self.v, it.v) + if self._dragged_on is not None: + scene.vertex_dragged.emit(DragState.OffOf, self.v, self._dragged_on.v) + self._dragged_on = it + return + if reset and self._dragged_on is not None: + scene.vertex_dragged.emit(DragState.OffOf, self.v, self._dragged_on.v) + self._dragged_on = None + + def mouseMoveEvent(self, e: QGraphicsSceneMouseEvent) -> None: + super().mouseMoveEvent(e) + if self.is_animated: + e.ignore() + return + scene = self.scene() + if TYPE_CHECKING: + assert isinstance(scene, GraphScene) + if self._mouse_move_drag_w_vertex(e): + return if self.is_dragging and len(scene.selectedItems()) == 1: - reset = True - for it in scene.items(): - if not it.sceneBoundingRect().intersects(self.sceneBoundingRect()): - continue - if isinstance(it, VItem) and vertex_is_w(self.ty) and get_w_partner(self.g, self.v) == it.v: - continue - if it == self._dragged_on: - reset = False - elif isinstance(it, VItem) and it != self: - scene.vertex_dragged.emit(DragState.Onto, self.v, it.v) - # If we previously hovered over a vertex, notify the scene that we - # are no longer - if self._dragged_on is not None: - scene.vertex_dragged.emit(DragState.OffOf, self.v, self._dragged_on.v) - self._dragged_on = it - return - if reset and self._dragged_on is not None: - scene.vertex_dragged.emit(DragState.OffOf, self.v, self._dragged_on.v) - self._dragged_on = None + self._mouse_move_drag_hover_onto(scene) e.ignore() - # TODO: Fix code complexity - # noqa: complexipy - def mouseReleaseEvent(self, e: QGraphicsSceneMouseEvent) -> None: # noqa: PLR0912 + def _snap_w_input_after_drag(self) -> None: + """Place W input next to its partner at the correct angle.""" + w_out = get_w_partner_vitem(self) + assert w_out is not None + w_in_pos = w_out.pos() + QPointF(0, W_INPUT_OFFSET * SCALE) + w_in_pos = rotate_point(w_in_pos, w_out.pos(), w_out.rotation()) + self.setPos(w_in_pos) + + def _emit_vertices_moved_or_dropped(self, scene: GraphScene) -> None: + if self._dragged_on is not None and len(scene.selectedItems()) == 1: + scene.vertex_dropped_onto.emit(self.v, self._dragged_on.v) + return + moved_vertices: list[VItem] = [] + for it in scene.selectedItems(): + if not isinstance(it, VItem): + continue + moved_vertices.append(it) + if vertex_is_w(it.ty): + partner = get_w_partner_vitem(it) + if partner: + moved_vertices.append(partner) + scene.vertices_moved.emit([(it.v, *pos_from_view(it.pos().x(), it.pos().y())) for it in moved_vertices]) + + def mouseReleaseEvent(self, e: QGraphicsSceneMouseEvent) -> None: # Unfortunately, Qt does not provide a "MoveFinished" event, so we have to # manually detect mouse releases. super().mouseReleaseEvent(e) if self.is_animated: e.ignore() return - if e.button() == Qt.MouseButton.LeftButton: - if self._old_pos is None or self._old_pos != self.pos(): - if self.ty == VertexType.W_INPUT: - # set the position of w_in to next to w_out at the same angle - w_out = get_w_partner_vitem(self) - assert w_out is not None - w_in_pos = w_out.pos() + QPointF(0, W_INPUT_OFFSET * SCALE) - w_in_pos = rotate_point(w_in_pos, w_out.pos(), w_out.rotation()) - self.setPos(w_in_pos) - scene = self.scene() - if TYPE_CHECKING: - assert isinstance(scene, GraphScene) - if self._dragged_on is not None and len(scene.selectedItems()) == 1: - scene.vertex_dropped_onto.emit(self.v, self._dragged_on.v) - else: - moved_vertices = [] - for it in scene.selectedItems(): - if not isinstance(it, VItem): - continue - moved_vertices.append(it) - if vertex_is_w(it.ty): - partner = get_w_partner_vitem(it) - if partner: - moved_vertices.append(partner) - scene.vertices_moved.emit([(it.v, *pos_from_view(it.pos().x(), it.pos().y())) for it in moved_vertices]) - self._dragged_on = None - self._old_pos = None - else: - e.ignore() - else: + if e.button() != Qt.MouseButton.LeftButton: + e.ignore() + return + if self._old_pos is not None and self._old_pos == self.pos(): e.ignore() + return + + if self.ty == VertexType.W_INPUT: + self._snap_w_input_after_drag() + scene = self.scene() + if TYPE_CHECKING: + assert isinstance(scene, GraphScene) + self._emit_vertices_moved_or_dropped(scene) + self._dragged_on = None + self._old_pos = None def remove_dummy_label(self) -> None: """Hides the dummy label items and clears their cache."""