diff --git a/zxlive/animations.py b/zxlive/animations.py index bd6aa53..d384f57 100644 --- a/zxlive/animations.py +++ b/zxlive/animations.py @@ -14,6 +14,7 @@ from .common import VT, GraphT, pos_to_view, ANIMATION_DURATION from .graphscene import GraphScene from .vitem import VItem, VItemAnimation, VITEM_UNSELECTED_Z, VITEM_SELECTED_Z, get_w_partner_vitem +from .eitem import EItem, EItemAnimation if TYPE_CHECKING: from .proof_panel import ProofPanel @@ -69,6 +70,13 @@ def _push_now(self, cmd: QUndoCommand, anim_after: Optional[QAbstractAnimation] anim_after.start() self.running_anim = anim_after + def set_anim(self, anim: QAbstractAnimation) -> None: + if self.running_anim: + self.running_anim.stop() + self.running_anim = anim + self.running_anim.start() + + def scale(it: VItem, target: float, duration: int, ease: QEasingCurve, start: Optional[float] = None) -> VItemAnimation: anim = VItemAnimation(it, VItem.Properties.Scale) @@ -89,6 +97,13 @@ def move(it: VItem, target: QPointF, duration: int, ease: QEasingCurve, start: O anim.setEasingCurve(ease) return anim +def edge_thickness(it: EItem, target: float, duration: int, ease: QEasingCurve, start: Optional[float] = None) -> EItemAnimation: + anim = EItemAnimation(it, EItem.Properties.Thickness, refresh=True) + anim.setDuration(duration) + anim.setStartValue(start or it.thickness) + anim.setEndValue(target) + anim.setEasingCurve(ease) + return anim def morph_graph(start: GraphT, end: GraphT, scene: GraphScene, to_start: Callable[[VT], Optional[VT]], to_end: Callable[[VT], Optional[VT]], duration: int, ease: QEasingCurve) -> QAbstractAnimation: diff --git a/zxlive/commands.py b/zxlive/commands.py index 54fa04e..68bc633 100644 --- a/zxlive/commands.py +++ b/zxlive/commands.py @@ -209,6 +209,48 @@ def redo(self) -> None: self._added_vert = self.g.add_vertex(self.vty, y,x) self.update_graph_view() +@dataclass +class AddNodeSnapped(BaseCommand): + """Adds a new spider positioned on an edge, replacing the original edge""" + x: float + y: float + vty: VertexType + e: ET + + added_vert: Optional[VT] = field(default=None, init=False) + s: Optional[VT] = field(default=None, init=False) + t: Optional[VT] = field(default=None, init=False) + _et: Optional[EdgeType] = field(default=None, init=False) + + def undo(self) -> None: + assert self.added_vert is not None + assert self.s is not None + assert self.t is not None + assert self._et is not None + self.g.remove_vertex(self.added_vert) + self.g.add_edge(self.g.edge(self.s,self.t), self._et) + self.update_graph_view() + + def redo(self) -> None: + y = round(self.y * display_setting.SNAP_DIVISION) / display_setting.SNAP_DIVISION + x = round(self.x * display_setting.SNAP_DIVISION) / display_setting.SNAP_DIVISION + self.added_vert = self.g.add_vertex(self.vty, y,x) + s,t = self.g.edge_st(self.e) + self._et = self.g.edge_type(self.e) + if self._et == EdgeType.SIMPLE: + self.g.add_edge(self.g.edge(s, self.added_vert), EdgeType.SIMPLE) + self.g.add_edge(self.g.edge(t, self.added_vert), EdgeType.SIMPLE) + elif self._et == EdgeType.HADAMARD: + self.g.add_edge(self.g.edge(s, self.added_vert), EdgeType.HADAMARD) + self.g.add_edge(self.g.edge(t, self.added_vert), EdgeType.SIMPLE) + else: + raise ValueError("Can't add spider between vertices connected by edge of type", str(self._et)) + self.s = s + self.t = t + + self.g.remove_edge(self.e) + self.update_graph_view() + @dataclass class AddWNode(BaseCommand): """Adds a new W node at a given position.""" @@ -245,7 +287,23 @@ def undo(self) -> None: self.update_graph_view() def redo(self) -> None: - self.g.add_edge(((self.u, self.v)), self.ety) + self.g.add_edge((self.u, self.v), self.ety) + self.update_graph_view() + +@dataclass +class AddEdges(BaseCommand): + """Adds multiple edges of the same type to a graph.""" + pairs: list[tuple[VT,VT]] + ety: EdgeType + + def undo(self) -> None: + for u, v in self.pairs: + self.g.remove_edge((u, v, self.ety)) + self.update_graph_view() + + def redo(self) -> None: + for u, v in self.pairs: + self.g.add_edge((u, v), self.ety) self.update_graph_view() diff --git a/zxlive/editor_base_panel.py b/zxlive/editor_base_panel.py index 82627a1..d04bc60 100644 --- a/zxlive/editor_base_panel.py +++ b/zxlive/editor_base_panel.py @@ -4,9 +4,9 @@ from enum import Enum from typing import Callable, Iterator, TypedDict -from PySide6.QtCore import QPoint, QSize, Qt, Signal +from PySide6.QtCore import QPoint, QPointF, QSize, Qt, Signal, QEasingCurve, QParallelAnimationGroup from PySide6.QtGui import (QAction, QColor, QIcon, QPainter, QPalette, QPen, - QPixmap) + QPixmap, QTransform) from PySide6.QtWidgets import (QApplication, QComboBox, QFrame, QGridLayout, QInputDialog, QLabel, QListView, QListWidget, QListWidgetItem, QScrollArea, QSizePolicy, @@ -17,15 +17,17 @@ from zxlive.sfx import SFXEnum from .base_panel import BasePanel, ToolbarSection -from .commands import (AddEdge, AddNode, AddWNode, ChangeEdgeColor, +from .commands import (BaseCommand, AddEdge, AddEdges, AddNode, AddNodeSnapped, AddWNode, ChangeEdgeColor, ChangeNodeType, ChangePhase, MoveNode, SetGraph, UpdateGraph) from .common import VT, GraphT, ToolType, get_data from .dialogs import show_error_msg -from .eitem import HAD_EDGE_BLUE +from .eitem import EItem, HAD_EDGE_BLUE, EItemAnimation +from .vitem import VItem, BLACK, VItemAnimation from .graphscene import EditGraphScene from .settings import display_setting -from .vitem import BLACK + +from . import animations class ShapeType(Enum): @@ -67,6 +69,7 @@ class EditorBasePanel(BasePanel): _curr_ety: EdgeType _curr_vty: VertexType + snap_vertex_edge = True def __init__(self, *actions: QAction) -> None: super().__init__(*actions) @@ -74,7 +77,7 @@ def __init__(self, *actions: QAction) -> None: self._curr_ety = EdgeType.SIMPLE def _toolbar_sections(self) -> Iterator[ToolbarSection]: - yield toolbar_select_node_edge(self) + yield from toolbar_select_node_edge(self) yield ToolbarSection(*self.actions()) def create_side_bar(self) -> None: @@ -98,6 +101,9 @@ def update_colors(self) -> None: def _tool_clicked(self, tool: ToolType) -> None: self.graph_scene.curr_tool = tool + def _snap_vertex_edge_clicked(self) -> None: + self.snap_vertex_edge = not self.snap_vertex_edge + def _vty_clicked(self, vty: VertexType) -> None: self._curr_vty = vty @@ -143,21 +149,73 @@ def delete_selection(self) -> None: else UpdateGraph(self.graph_view,new_g) self.undo_stack.push(cmd) - def add_vert(self, x: float, y: float) -> None: + def add_vert(self, x: float, y: float, edges: list[EItem]) -> None: + """Add a vertex at point (x,y). `edges` is a list of EItems that are underneath the current position. + We will try to connect the vertex to an edge. + """ + cmd: BaseCommand + if self.snap_vertex_edge and edges and self._curr_vty != VertexType.W_OUTPUT: + # Trying to snap vertex to an edge + for it in edges: + e = it.e + g = self.graph_scene.g + if self.graph_scene.g.edge_type(e) not in (EdgeType.SIMPLE, EdgeType.HADAMARD): + continue + cmd = AddNodeSnapped(self.graph_view, x, y, self._curr_vty, e) + self.play_sound_signal.emit(SFXEnum.THATS_A_SPIDER) + self.undo_stack.push(cmd) + g = cmd.g + group = QParallelAnimationGroup() + for e in [next(g.edges(cmd.s, cmd.added_vert)), next(g.edges(cmd.t, cmd.added_vert))]: + eitem = self.graph_scene.edge_map[e][0] + anim = animations.edge_thickness(eitem,3,400, + QEasingCurve(QEasingCurve.Type.InCubic),start=7) + group.addAnimation(anim) + self.undo_stack.set_anim(group) + return + cmd = AddWNode(self.graph_view, x, y) if self._curr_vty == VertexType.W_OUTPUT \ - else AddNode(self.graph_view, x, y, self._curr_vty) + else AddNode(self.graph_view, x, y, self._curr_vty) + self.play_sound_signal.emit(SFXEnum.THATS_A_SPIDER) self.undo_stack.push(cmd) - def add_edge(self, u: VT, v: VT) -> None: + def add_edge(self, u: VT, v: VT, verts: list[VItem]) -> None: + """Add an edge between vertices u and v. `verts` is a list of VItems that collide with the edge. + """ + cmd: BaseCommand graph = self.graph_view.graph_scene.g if vertex_is_w(graph.type(u)) and get_w_partner(graph, u) == v: return None if graph.type(u) == VertexType.W_INPUT and len(graph.neighbors(u)) >= 2 or \ graph.type(v) == VertexType.W_INPUT and len(graph.neighbors(v)) >= 2: return None - cmd = AddEdge(self.graph_view, u, v, self._curr_ety) + # We will try to connect all the vertices together in order + # First we filter out the vertices that are not compatible with the edge. + verts = [vitem for vitem in verts if not graph.type(vitem.v) == VertexType.W_INPUT] # we will be adding two edges, which is not compatible with W_INPUT + # but first we check if there any vertices that we do want to additionally connect. + if not self.snap_vertex_edge or not verts: + cmd = AddEdge(self.graph_view, u, v, self._curr_ety) + self.undo_stack.push(cmd) + return + + ux, uy = graph.row(u), graph.qubit(u) + # Line was drawn from u to v, we want to order vs with the earlier items first. + def dist(vitem: VItem) -> float: + return (graph.row(vitem.v) - ux)**2 + (graph.qubit(vitem.v) - uy)**2 # type: ignore + verts.sort(key=dist) + vs = [vitem.v for vitem in verts] + pairs = [(u, vs[0])] + for i in range(1, len(vs)): + pairs.append((vs[i-1],vs[i])) + pairs.append((vs[-1],v)) + cmd = AddEdges(self.graph_view, pairs, self._curr_ety) self.undo_stack.push(cmd) + group = QParallelAnimationGroup() + for vitem in verts: + anim = animations.scale(vitem,1.0,400,QEasingCurve(QEasingCurve.Type.InCubic),start=1.3) + group.addAnimation(anim) + self.undo_stack.set_anim(group) def vert_moved(self, vs: list[tuple[VT, float, float]]) -> None: self.undo_stack.push(MoveNode(self.graph_view, vs)) @@ -288,7 +346,7 @@ def _text_changed(self, name: str, text: str) -> None: self.parent_panel.graph.variable_types[name] = True -def toolbar_select_node_edge(parent: EditorBasePanel) -> ToolbarSection: +def toolbar_select_node_edge(parent: EditorBasePanel) -> Iterator[ToolbarSection]: icon_size = QSize(32, 32) select = QToolButton(parent) # Selected by default vertex = QToolButton(parent) @@ -312,7 +370,16 @@ def toolbar_select_node_edge(parent: EditorBasePanel) -> ToolbarSection: select.clicked.connect(lambda: parent._tool_clicked(ToolType.SELECT)) vertex.clicked.connect(lambda: parent._tool_clicked(ToolType.VERTEX)) edge.clicked.connect(lambda: parent._tool_clicked(ToolType.EDGE)) - return ToolbarSection(select, vertex, edge, exclusive=True) + yield ToolbarSection(select, vertex, edge, exclusive=True) + + snap = QToolButton(parent) + snap.setCheckable(True) + snap.setChecked(True) + snap.setIcon(QIcon(get_data("icons/vertex-snap-to-edge.svg"))) + snap.setToolTip("Snap vertices to the edge beneath them when adding vertices or edges (f)") + snap.setShortcut("f") + snap.clicked.connect(lambda: parent._snap_vertex_edge_clicked()) + yield ToolbarSection(snap) def create_list_widget(parent: EditorBasePanel, diff --git a/zxlive/eitem.py b/zxlive/eitem.py index b2a72e1..ac86747 100644 --- a/zxlive/eitem.py +++ b/zxlive/eitem.py @@ -15,9 +15,10 @@ from __future__ import annotations from math import sqrt -from typing import Optional, Any, TYPE_CHECKING +from typing import Optional, Any, TYPE_CHECKING, Union +from enum import Enum -from PySide6.QtCore import QPointF +from PySide6.QtCore import QPointF, QVariantAnimation, QAbstractAnimation from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsPathItem, QGraphicsItem, \ QGraphicsSceneMouseEvent, QStyleOptionGraphicsItem, QWidget, QStyle from PySide6.QtGui import QPen, QPainter, QColor, QPainterPath @@ -35,6 +36,13 @@ class EItem(QGraphicsPathItem): """A QGraphicsItem representing an edge""" + # Set of animations that are currently running on this vertex + active_animations: set[EItemAnimation] + + class Properties(Enum): + """Properties of an EItem that can be animated.""" + Thickness = 1 + def __init__(self, graph_scene: GraphScene, e: ET, s_item: VItem, t_item: VItem, curve_distance: float = 0) -> None: super().__init__() self.setZValue(EITEM_Z) @@ -46,6 +54,7 @@ def __init__(self, graph_scene: GraphScene, e: ET, s_item: VItem, t_item: VItem, self.s_item = s_item self.t_item = t_item self.curve_distance = curve_distance + self.active_animations = set() s_item.adj_items.add(self) t_item.adj_items.add(self) self.selection_node = QGraphicsEllipseItem(-0.1 * SCALE, -0.1 * SCALE, 0.2 * SCALE, 0.2 * SCALE) @@ -58,6 +67,7 @@ def __init__(self, graph_scene: GraphScene, e: ET, s_item: VItem, t_item: VItem, self.is_mouse_pressed = False self.is_dragging = False self._old_pos: Optional[QPointF] = None + self.thickness: float = 3 self.refresh() @@ -65,6 +75,10 @@ def __init__(self, graph_scene: GraphScene, e: ET, s_item: VItem, t_item: VItem, def g(self) -> GraphT: return self.graph_scene.g + @property + def is_animated(self) -> bool: + return len(self.active_animations) > 0 + def refresh(self) -> None: """Call whenever source or target moves or edge data changes""" @@ -72,7 +86,7 @@ def refresh(self) -> None: self.g.edge_type(self.e) != EdgeType.W_IO) # set color/style according to edge type pen = QPen() - pen.setWidthF(3) + pen.setWidthF(self.thickness) if self.g.edge_type(self.e) == EdgeType.HADAMARD: pen.setColor(QColor(HAD_EDGE_BLUE)) pen.setDashPattern([4.0, 2.0]) @@ -197,3 +211,68 @@ def compute_perpendicular_direction(source_pos: QPointF, target_pos: QPointF) -> direction = direction / norm perpendicular = QPointF(-direction.y(), direction.x()) return perpendicular + + +class EItemAnimation(QVariantAnimation): + """Animator for edge graphics items. + + This animator lets the edge know that its being animated which stops any + interaction with the user. Furthermore, this animator + ensures that it's not garbage collected until the animation is finished, so there is + no need to hold onto a reference of this class.""" + + _it: Optional[EItem] + prop: EItem.Properties + refresh: bool # Whether the item is refreshed at each frame + + e: Optional[ET] + + def __init__(self, item: Union[EItem, ET], property: EItem.Properties, + scene: Optional[GraphScene] = None, refresh: bool = False) -> None: + super().__init__() + self.e = None + self._it = None + self.scene: Optional[GraphScene] = None + if isinstance(item, EItem): + self._it = item + elif scene is None: + raise ValueError("Scene is required to obtain EItem from edge ET") + else: + self.e = item + self.scene = scene + self.prop = property + self.refresh = refresh + self.stateChanged.connect(self._on_state_changed) + + @property + def it(self) -> EItem: + if self._it is None and self.scene is not None and self.e is not None: + self._it = self.scene.edge_map[self.e] + assert self._it is not None + return self._it + + def _on_state_changed(self, state: QAbstractAnimation.State) -> None: + if state == QAbstractAnimation.State.Running and self not in self.it.active_animations: + # Stop all animations that target the same property + for anim in self.it.active_animations.copy(): + if anim.prop == self.prop: + anim.stop() + self.it.active_animations.add(self) + elif state == QAbstractAnimation.State.Stopped: + self.it.active_animations.remove(self) + elif state == QAbstractAnimation.State.Paused: + # TODO: Once we use pausing, we should decide what to do here. + # Note that we cannot just remove ourselves from the set since the garbage + # collector will eat us in that case. We'll probably need something like + # `it.paused_animations` + pass + + def updateCurrentValue(self, value: Any) -> None: + if self.state() != QAbstractAnimation.State.Running: + return + + if self.prop == EItem.Properties.Thickness: + self.it.thickness = value + + if self.refresh: + self.it.refresh() diff --git a/zxlive/graphscene.py b/zxlive/graphscene.py index 118b74c..3b5ebf9 100644 --- a/zxlive/graphscene.py +++ b/zxlive/graphscene.py @@ -17,16 +17,19 @@ from typing import Optional, Iterator, Iterable -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QBrush, QColor, QTransform +from PySide6.QtCore import Qt, Signal, QRectF +from PySide6.QtGui import QBrush, QColor, QTransform, QPainterPath from PySide6.QtWidgets import QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsItem from pyzx.graph.base import EdgeType from pyzx.graph import GraphDiff -from .common import VT, ET, GraphT, ToolType, pos_from_view, OFFSET_X, OFFSET_Y + + +from .common import SCALE, VT, ET, GraphT, ToolType, pos_from_view, OFFSET_X, OFFSET_Y from .vitem import VItem from .eitem import EItem, EDragItem +from .settings import display_setting class GraphScene(QGraphicsScene): @@ -110,8 +113,8 @@ def update_graph(self, new: GraphT, select_new: bool = False) -> None: v_item = self.vertex_map[v] if v_item.phase_item: self.removeItem(v_item.phase_item) - for anim in v_item.active_animations.copy(): - anim.stop() + for anim_v in v_item.active_animations.copy(): + anim_v.stop() selected_vertices.discard(v) self.removeItem(v_item) @@ -120,6 +123,8 @@ def update_graph(self, new: GraphT, select_new: bool = False) -> None: e_item = self.edge_map[e][edge_idx] if e_item.selection_node: self.removeItem(e_item.selection_node) + for anim_e in e_item.active_animations.copy(): + anim_e.stop() self.removeItem(e_item) self.edge_map[e].pop(edge_idx) s, t = self.g.edge_st(e) @@ -231,8 +236,8 @@ class EditGraphScene(GraphScene): # Signals to handle addition of vertices and edges. # Note that we have to set the argument types to `object`, # otherwise it doesn't work for some reason... - vertex_added = Signal(object, object) # Actual types: float, float - edge_added = Signal(object, object) # Actual types: VT, VT + vertex_added = Signal(object, object, object) # Actual types: float, float, list[EItem] + edge_added = Signal(object, object, object) # Actual types: VT, VT, list[VItem] # Currently selected edge type for preview when dragging # to add a new edge @@ -291,12 +296,30 @@ def mouseReleaseEvent(self, e: QGraphicsSceneMouseEvent) -> None: def add_vertex(self, e: QGraphicsSceneMouseEvent) -> None: p = e.scenePos() - self.vertex_added.emit(*pos_from_view(p.x(), p.y())) + # create a rectangle around the mouse position which will be used to check of edge intersections + snap = display_setting.SNAP_DIVISION + rect = QRectF(p.x() - SCALE/(2*snap), p.y() - SCALE/(2*snap), SCALE/snap, SCALE/snap) + # edges under current mouse position + edges: list[EItem] = [e for e in self.items(rect, deviceTransform=QTransform()) if isinstance(e,EItem)] + self.vertex_added.emit(*pos_from_view(p.x(), p.y()), edges) def add_edge(self, e: QGraphicsSceneMouseEvent) -> None: assert self._drag is not None self.removeItem(self._drag) + v1 = self._drag.start + self._drag = None for it in self.items(e.scenePos(), deviceTransform=QTransform()): if isinstance(it, VItem): - self.edge_added.emit(self._drag.start.v, it.v) - self._drag = None + v2 = it + break + else: # It wasn't actually dropped on a vertex + e.ignore() + return + path = QPainterPath(v1.pos()) + path.lineTo(e.scenePos()) + colliding_verts = [] + for it in self.items(path, Qt.ItemSelectionMode.IntersectsItemShape, Qt.SortOrder.DescendingOrder, deviceTransform=QTransform()): + if isinstance(it, VItem) and it not in (v1,v2): + colliding_verts.append(it) + self.edge_added.emit(v1.v,v2.v,colliding_verts) + diff --git a/zxlive/icons/vertex-snap-to-edge.svg b/zxlive/icons/vertex-snap-to-edge.svg new file mode 100644 index 0000000..2f0a30d --- /dev/null +++ b/zxlive/icons/vertex-snap-to-edge.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/zxlive/proof_panel.py b/zxlive/proof_panel.py index f38dd06..264f880 100644 --- a/zxlive/proof_panel.py +++ b/zxlive/proof_panel.py @@ -154,7 +154,7 @@ def _wand_trace_finished(self, trace: WandTrace) -> None: def _magic_hopf(self, trace: WandTrace) -> bool: if not all(isinstance(item, EItem) for item in trace.hit): return False - edges = [item.e for item in trace.hit] + edges: list[ET] = [item.e for item in trace.hit] # type: ignore # We know that the type of `item` is `EItem` because of the check above if len(edges) == 0: return False if not all(edge == edges[0] for edge in edges): diff --git a/zxlive/rewrite_action.py b/zxlive/rewrite_action.py index ea54de8..8fa624b 100644 --- a/zxlive/rewrite_action.py +++ b/zxlive/rewrite_action.py @@ -146,14 +146,14 @@ def tooltip(self) -> str: buffer = QBuffer() buffer.open(QIODevice.OpenModeFlag.WriteOnly) pixmap.save(buffer, "PNG", quality=100) - image = bytes(buffer.data().toBase64()).decode() + image = bytes(buffer.data().toBase64()).decode() # type: ignore # This gives an overloading error, but QByteArray can be converted to bytes else: pixmap = QPixmap() pixmap.load(get_data("tooltips/"+self.picture_path)) buffer = QBuffer() buffer.open(QIODevice.OpenModeFlag.WriteOnly) pixmap.save(buffer, "PNG", quality=100) - image = bytes(buffer.data().toBase64()).decode() + image = bytes(buffer.data().toBase64()).decode() #type: ignore # This gives an overloading error, but QByteArray can be converted to bytes self.tooltip_str = ''.format(image) + self.tooltip_str self.picture_path = None return self.tooltip_str diff --git a/zxlive/rule_panel.py b/zxlive/rule_panel.py index dbc4cd6..d78c223 100644 --- a/zxlive/rule_panel.py +++ b/zxlive/rule_panel.py @@ -15,7 +15,8 @@ from .editor_base_panel import EditorBasePanel from .graphscene import EditGraphScene from .graphview import RuleEditGraphView - +from .eitem import EItem +from .vitem import VItem class RulePanel(EditorBasePanel): """Panel for the Rule editor of ZXLive.""" @@ -87,12 +88,12 @@ def vert_moved(self, vs: list[tuple[VT, float, float]]) -> None: super().vert_moved(vs) self.update_io_labels(self.graph_scene) - def add_vert(self, x: float, y: float) -> None: - super().add_vert(x, y) + def add_vert(self, x: float, y: float, edges: list[EItem]) -> None: + super().add_vert(x, y, edges) self.update_io_labels(self.graph_scene) - def add_edge(self, u: VT, v: VT) -> None: - super().add_edge(u, v) + def add_edge(self, u: VT, v: VT, verts: list[VItem]) -> None: + super().add_edge(u, v, verts) self.update_io_labels(self.graph_scene) def update_io_labels(self, scene: EditGraphScene) -> None: