Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a106b48
Highlight changed nodes and edges per rewrite step
dorakingx Feb 26, 2026
c6859a3
Refine rewrite highlighting and clean debug instrumentation
dorakingx Feb 27, 2026
d3230a1
Clean up proof panel debug logging
dorakingx Feb 27, 2026
0ab3e51
Fix Spider Fusion edge highlighting: use highlight_match_pairs + inci…
dorakingx Feb 27, 2026
9c84205
Fix Magic Wand unfuse highlighting: only highlight selected/split ver…
dorakingx Feb 28, 2026
5f8095d
Add View menu toggle for rewrite highlights; refresh on settings change
dorakingx Feb 28, 2026
34bcf23
Prune highlight code; restore highlight_verts for color change/strong…
dorakingx Feb 28, 2026
c757e82
Optimize move_to_step: remove unused g_next, compute current_verts once
dorakingx Feb 28, 2026
d1d2c22
Fix mypy: no-redef in proof.py, step_view type ProofStepView in comma…
dorakingx Feb 28, 2026
09a9ffa
Fix pyflakes: remove unused imports in commands.py, remove unused var…
dorakingx Mar 2, 2026
a22f317
Support MATCH_COMPOUND in rewrite highlighting: pass highlight_verts …
dorakingx Mar 2, 2026
0f321e8
Highlight: custom rules, add identity, edge-only, pretty colors
dorakingx Mar 2, 2026
68746d1
Refactor: use pyzx edges helper in _edges_between
dorakingx Mar 2, 2026
9a0cf37
Fix mypy no-redef by renaming edge-only set
dorakingx Mar 2, 2026
09142ee
Highlight: fix strong complementarity animation and vertex coverage
dorakingx Mar 2, 2026
c058ec2
Merge master into feature/issue-190-highlight-rewrites and resolve co…
dorakingx Mar 2, 2026
7349ae7
Remove _edges_between wrapper, use g.edges() directly (per review)
dorakingx Mar 3, 2026
bbc2c65
Revert VItemAnimation robustness changes per review
dorakingx Mar 3, 2026
6b2bf48
Add generic highlight fallbacks and local change-z-to-x rule
dorakingx Mar 3, 2026
be767cc
Remove temporary debug logging helpers
dorakingx Mar 3, 2026
bf11dfa
Use CustomRule for change-z-to-x
dorakingx Mar 3, 2026
59e2537
Refine highlight helpers and silence complexity lints
dorakingx Mar 7, 2026
b44c174
Merge origin/master into feature/issue-190-highlight-rewrites
dorakingx Mar 7, 2026
32e1d63
Highlight parallel-edge magic wand rewrites
dorakingx Mar 7, 2026
dd0f25d
Refine proof rewrite highlighting and satisfy linters
dorakingx Mar 17, 2026
4ea7e12
Ensure proof module uses postponed annotations
dorakingx Mar 17, 2026
000c097
Refactor VItem.refresh to reduce branching
dorakingx Mar 23, 2026
0c26bf3
Fix mypy no-redef in rewrite highlight helper.
dorakingx Mar 23, 2026
179720a
Split VItem mouse drag handlers for complexipy.
dorakingx Mar 25, 2026
4fd63e5
Set QT_QPA_PLATFORM for headless pytest
dorakingx Mar 25, 2026
134a793
Add missing galois dependency for pyzx web
dorakingx Mar 25, 2026
9877f62
Lazy-load SFX to avoid Qt multimedia segfault
dorakingx Mar 25, 2026
b3cbb02
Revert "Fixes #467 remove the 2 minimum edges condition from can_unfuse"
dorakingx Mar 25, 2026
f792132
Fix mypy and avoid CI segfault in headless tests
dorakingx Mar 25, 2026
2be102a
Restore complexipy ignore for session restore
dorakingx Mar 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions PR_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies = [
"numpy",
"shapely",
"pyperclip",
"galois",
"imageio",
"packaging",
"matplotlib>=3.3",
Expand Down Expand Up @@ -72,6 +73,10 @@ reportWildcardImportFromLibrary = "none"

[tool.ruff]
line-length = 120
exclude = [
".venv311",
"build",
]

[tool.ruff.lint]
select = [
Expand Down Expand Up @@ -101,3 +106,7 @@ disallow_untyped_defs = true
disable_error_code = [
"import",
]
exclude = [
"^build/",
"^test/",
]
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 143 additions & 0 deletions test/test_highlight.py
Original file line number Diff line number Diff line change
@@ -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())

27 changes: 21 additions & 6 deletions zxlive/animations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions zxlive/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was it necessary to add type ignore here? it used to work previously.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, and thanks for catching this.

The # type: ignore[attr-defined] here is only working around a mismatch between Qt’s runtime API and the current PySide6 type stubs. At runtime, QStyleHints does provide setColorScheme(Qt.ColorScheme) starting from Qt 6.5, and the call works correctly on macOS. However, the PySide6 .pyi stubs in our environment only declare the colorScheme() getter on QStyleHints and do not yet expose the corresponding setColorScheme(...) setter. When we enabled mypy zxlive in CI, that missing stub manifested as a false-positive attr-defined error, even though the method is available and used in Qt’s own QML code.

Because of that, the type: ignore is not hiding a real bug, it is just acknowledging that the stubs are currently behind the actual Qt/PySide runtime. Once the PySide6 stubs grow a proper setColorScheme declaration, we can safely remove this ignore.

zxl.exec_()
3 changes: 1 addition & 2 deletions zxlive/base_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 22 additions & 18 deletions zxlive/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -465,33 +464,38 @@ 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
self.step_view.selectionModel().blockSignals(True)
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):
Expand Down
3 changes: 1 addition & 2 deletions zxlive/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading