文章目录
从实现删除功能到完善的经验总结:动态连线和右键菜单的实现与调试
在这个阶段,我们的目标是实现一个 动态图形编辑工具,包括:
- 删除功能的实现:支持删除图形和箭头线。
- 动态连线更新:图形移动时,箭头动态跟随。
- 右键菜单功能:为每个图形和箭头添加右键菜单,支持常用操作。
在实现这些功能的过程中,我们经历了一些困难和调试过程,以下是详细的实现和调试总结。
一、删除功能的实现与调试
功能需求
- 图形删除时:删除其相关的所有箭头线。
- 箭头删除时:从起点和终点图形的连接列表中移除该箭头。
- 右键菜单触发:通过右键菜单实现删除操作。
初步实现代码
我们在 DraggableRect
和 DraggableEllipse
中增加了 delete_item
方法,用于删除图形及其相关的箭头:
def delete_item(self):
"""删除该图形和相关箭头"""
while self.connections: # 循环删除与图形相关的所有箭头
arrow = self.connections.pop(0) # 确保从列表中正确移除
arrow.delete_item()
self.scene().removeItem(self) # 删除图形本身
对于箭头,我们实现了类似的删除逻辑:
def delete_item(self):
"""删除箭头和箭头头部"""
if self in self.start_item.connections:
self.start_item.connections.remove(self)
if self in self.end_item.connections:
self.end_item.connections.remove(self)
if self.scene_ref: # 确保场景引用存在
self.scene_ref.removeItem(self.arrow_head) # 删除箭头头部
self.scene().removeItem(self) # 删除箭头本身
Debug 问题 1:删除箭头时报错 AttributeError
问题表现:删除箭头时,报错提示 AttributeError: 'NoneType' object has no attribute 'removeItem'
。
原因分析:箭头的场景引用未正确保存,导致 self.scene_ref
为 None
。
解决方法:
- 在箭头初始化时保存场景引用:
if self.start_item.scene(): self.scene_ref = self.start_item.scene() # 存储场景引用 self.scene_ref.addItem(self.arrow_head)
- 确保删除箭头头部时,
scene_ref
存在。
Debug 问题 2:图形删除时箭头未同步删除
问题表现:删除图形时,箭头没有被移除或需要重复点击才能删除图形。
原因分析:
- 图形的连接列表未正确清空。
connections.pop(0)
操作有时会跳过某些箭头。
解决方法:
- 循环删除所有箭头,确保
connections
列表为空:while self.connections: arrow = self.connections.pop(0) arrow.delete_item()
- 在
delete_item
方法中,正确从connections
列表移除箭头。
二、动态连线的实现与调试
功能需求
- 图形移动时,箭头的起点和终点应实时更新。
- 箭头动态连接图形的边界,而不是固定在图形中心。
初步实现代码
在箭头的 update_position
方法中,我们通过计算图形之间的边界交点来动态更新箭头的位置:
def update_position(self):
"""更新箭头位置"""
start_pos = self._get_intersection(self.start_item, self.end_item)
end_pos = self._get_intersection(self.end_item, self.start_item)
self.setLine(start_pos.x(), start_pos.y(), end_pos.x(), end_pos.y())
计算交点的方法 _get_intersection
:
def _get_intersection(self, item1, item2):
"""计算两个图形之间的交点"""
rect1 = item1.sceneBoundingRect()
rect2 = item2.sceneBoundingRect()
line = QtCore.QLineF(rect1.center(), rect2.center())
edges = [
QtCore.QLineF(rect1.topLeft(), rect1.topRight()), # 上边
QtCore.QLineF(rect1.topRight(), rect1.bottomRight()), # 右边
QtCore.QLineF(rect1.bottomRight(), rect1.bottomLeft()), # 下边
QtCore.QLineF(rect1.bottomLeft(), rect1.topLeft()), # 左边
]
for edge_line in edges:
intersection_type, intersection_point = line.intersects(edge_line)
if intersection_type == QtCore.QLineF.IntersectionType.BoundedIntersection:
return intersection_point
return rect1.center() # 默认返回中心点
Debug 问题 1:箭头不随图形移动更新
问题表现:移动图形时,箭头仍指向原来的位置。
原因分析:箭头未监听图形的移动事件。
解决方法:
在图形的 itemChange
方法中,通知所有连接的箭头更新位置:
def itemChange(self, change, value):
if change == QtWidgets.QGraphicsItem.GraphicsItemChange.ItemPositionChange:
if self.move_enabled:
for arrow in self.connections:
arrow.update_position()
return super().itemChange(change, value)
三、右键菜单功能的完善与调试
功能需求
- 图形右键菜单:支持删除、复制、连线、更改颜色、重置位置、显示属性。
- 箭头右键菜单:支持删除和显示属性。
Debug 问题 1:菜单操作顺序错误
问题表现:当删除箭头后,无法再触发其他菜单操作。
原因分析:删除操作未清理干净箭头的引用。
解决方法:在箭头的删除方法中,确保所有相关图形的连接列表正确更新:
def delete_item(self):
"""删除箭头和箭头头部"""
if self in self.start_item.connections:
self.start_item.connections.remove(self)
if self in self.end_item.connections:
self.end_item.connections.remove(self)
if self.scene_ref:
self.scene_ref.removeItem(self.arrow_head)
self.scene().removeItem(self)
Debug 问题 2:更改颜色时未刷新颜色
问题表现:更改颜色后,颜色对话框的选择未立即生效。
解决方法:在 change_color
方法中,通过 QBrush
更新图形颜色:
def change_color(self):
color = QtWidgets.QColorDialog.getColor(self.color, None, "选择颜色")
if color.isValid():
self.color = color
self.setBrush(QtGui.QBrush(color))
四、最终完成的代码功能总结
通过多次调试与优化,我们实现了以下功能:
- 动态删除功能:
- 删除图形时同步删除相关箭头。
- 删除箭头时从连接图形中移除引用。
- 箭头动态更新:
- 图形移动时,箭头的起点和终点实时更新。
- 箭头动态连接到图形边界。
- 丰富的右键菜单:
- 支持删除、复制、连线、更改颜色、重置位置、显示属性。
- 模块化设计:
- 图形(矩形和椭圆)和箭头类分离,便于扩展其他图形。
五、完整代码展示
import pyqtgraph as pg
from pyqtgraph.Qt import QtWidgets, QtGui, QtCore
import math
class DraggableRect(QtWidgets.QGraphicsRectItem):
"""可拖动的矩形"""
def __init__(self, x, y, w, h, color):
super().__init__(x, y, w, h)
self.setBrush(QtGui.QBrush(color))
self.color = color # 保存颜色信息
self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) # 启用几何更新事件
self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable) # 默认允许移动
self.connections = [] # 存储与该矩形连接的箭头
self.move_enabled = True # 是否允许移动
self.initial_pos = None # 初始位置,用于重置
def itemChange(self, change, value):
"""在矩形移动时更新所有连接的箭头"""
if change == QtWidgets.QGraphicsItem.GraphicsItemChange.ItemPositionChange:
if self.move_enabled: # 只有在移动模式下才能移动
for arrow in self.connections:
arrow.update_position()
else:
return self.pos() # 返回当前坐标,不允许移动
return super().itemChange(change, value)
def set_movable(self, movable):
"""设置矩形是否可移动"""
self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, movable)
self.move_enabled = movable
def contextMenuEvent(self, event):
"""右键菜单"""
menu = QtWidgets.QMenu()
delete_action = menu.addAction("删除")
copy_action = menu.addAction("复制")
line_action = menu.addAction("连线")
color_action = menu.addAction("更改颜色")
reset_action = menu.addAction("重置位置")
properties_action = menu.addAction("显示属性")
action = menu.exec(event.screenPos())
if action == delete_action:
self.delete_item()
elif action == copy_action:
self.copy_item()
elif action == line_action:
self.scene().start_connection(self)
elif action == color_action:
self.change_color()
elif action == reset_action:
self.reset_position()
elif action == properties_action:
self.show_properties()
def delete_item(self):
"""删除该图形和相关箭头"""
while self.connections: # 循环删除与图形相关的所有箭头
arrow = self.connections.pop(0) # 确保从列表中正确移除
arrow.delete_item()
self.scene().removeItem(self) # 删除图形本身
def copy_item(self):
"""复制图形"""
rect = DraggableRect(-50, -50, 100, 100, self.color)
rect.setPos(self.scenePos() + QtCore.QPointF(20, 20)) # 偏移一点,避免重叠
self.scene().addItem(rect)
def change_color(self):
"""更改颜色"""
color = QtWidgets.QColorDialog.getColor(self.color, None, "选择颜色")
if color.isValid():
self.color = color
self.setBrush(QtGui.QBrush(color))
def reset_position(self):
"""重置到初始位置"""
if self.initial_pos:
self.setPos(self.initial_pos)
def show_properties(self):
"""显示属性信息"""
properties = f"位置: {self.scenePos().x():.2f}, {self.scenePos().y():.2f}\n" \
f"颜色: {self.color.name()}\n" \
f"连接数: {len(self.connections)}"
QtWidgets.QMessageBox.information(None, "属性信息", properties)
class DraggableEllipse(QtWidgets.QGraphicsEllipseItem):
"""可拖动的椭圆"""
def __init__(self, x, y, w, h, color):
super().__init__(x, y, w, h)
self.setBrush(QtGui.QBrush(color))
self.color = color # 保存颜色信息
self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) # 启用几何更新事件
self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable) # 默认允许移动
self.connections = [] # 存储与该椭圆连接的箭头
self.move_enabled = True # 是否允许移动
self.initial_pos = None # 初始位置,用于重置
def itemChange(self, change, value):
"""在椭圆移动时更新所有连接的箭头"""
if change == QtWidgets.QGraphicsItem.GraphicsItemChange.ItemPositionChange:
if self.move_enabled: # 只有在移动模式下才能移动
for arrow in self.connections:
arrow.update_position()
else:
return self.pos() # 返回当前坐标,不允许移动
return super().itemChange(change, value)
def set_movable(self, movable):
"""设置椭圆是否可移动"""
self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, movable)
self.move_enabled = movable
def contextMenuEvent(self, event):
"""右键菜单"""
menu = QtWidgets.QMenu()
delete_action = menu.addAction("删除")
copy_action = menu.addAction("复制")
line_action = menu.addAction("连线")
color_action = menu.addAction("更改颜色")
reset_action = menu.addAction("重置位置")
properties_action = menu.addAction("显示属性")
action = menu.exec(event.screenPos())
if action == delete_action:
self.delete_item()
elif action == copy_action:
self.copy_item()
elif action == line_action:
self.scene().start_connection(self)
elif action == color_action:
self.change_color()
elif action == reset_action:
self.reset_position()
elif action == properties_action:
self.show_properties()
def delete_item(self):
"""删除该图形和相关箭头"""
while self.connections: # 循环删除与图形相关的所有箭头
arrow = self.connections.pop(0) # 确保从列表中正确移除
arrow.delete_item()
self.scene().removeItem(self) # 删除图形本身
def copy_item(self):
"""复制图形"""
ellipse = DraggableEllipse(-50, -50, 100, 100, self.color)
ellipse.setPos(self.scenePos() + QtCore.QPointF(20, 20)) # 偏移一点,避免重叠
self.scene().addItem(ellipse)
def change_color(self):
"""更改颜色"""
color = QtWidgets.QColorDialog.getColor(self.color, None, "选择颜色")
if color.isValid():
self.color = color
self.setBrush(QtGui.QBrush(color))
def reset_position(self):
"""重置到初始位置"""
if self.initial_pos:
self.setPos(self.initial_pos)
def show_properties(self):
"""显示属性信息"""
properties = f"位置: {self.scenePos().x():.2f}, {self.scenePos().y():.2f}\n" \
f"颜色: {self.color.name()}\n" \
f"连接数: {len(self.connections)}"
QtWidgets.QMessageBox.information(None, "属性信息", properties)
class ArrowLine(QtWidgets.QGraphicsLineItem):
"""箭头连线"""
def __init__(self, start_item, end_item):
super().__init__()
self.start_item = start_item
self.end_item = end_item
self.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0), 2)) # 设置线条颜色和粗细
self.arrow_head = QtWidgets.QGraphicsPolygonItem() # 箭头头部
self.arrow_head.setBrush(QtGui.QBrush(QtGui.QColor(0, 0, 0))) # 箭头颜色
self.scene_ref = None # 存储场景引用
self.update_position()
# 将箭头添加到场景
if self.start_item.scene():
self.scene_ref = self.start_item.scene() # 存储场景引用
self.scene_ref.addItem(self.arrow_head)
# 将箭头添加到图形的连接列表中
self.start_item.connections.append(self)
self.end_item.connections.append(self)
def update_position(self):
"""更新箭头位置"""
start_pos = self._get_intersection(self.start_item, self.end_item)
end_pos = self._get_intersection(self.end_item, self.start_item)
self.setLine(start_pos.x(), start_pos.y(), end_pos.x(), end_pos.y())
# 箭头大小
arrow_size = 15
dx = end_pos.x() - start_pos.x()
dy = end_pos.y() - start_pos.y()
angle = math.atan2(dy, dx)
p1 = QtCore.QPointF(
end_pos.x() - arrow_size * math.cos(angle - math.radians(30)),
end_pos.y() - arrow_size * math.sin(angle - math.radians(30))
)
p2 = QtCore.QPointF(
end_pos.x() - arrow_size * math.cos(angle + math.radians(30)),
end_pos.y() - arrow_size * math.sin(angle + math.radians(30))
)
polygon = QtGui.QPolygonF([QtCore.QPointF(end_pos), p1, p2])
self.arrow_head.setPolygon(polygon)
def _get_intersection(self, item1, item2):
"""计算两个图形之间的交点"""
rect1 = item1.sceneBoundingRect()
rect2 = item2.sceneBoundingRect()
line = QtCore.QLineF(rect1.center(), rect2.center())
edges = [
QtCore.QLineF(rect1.topLeft(), rect1.topRight()),
QtCore.QLineF(rect1.topRight(), rect1.bottomRight()),
QtCore.QLineF(rect1.bottomRight(), rect1.bottomLeft()),
QtCore.QLineF(rect1.bottomLeft(), rect1.topLeft()),
]
for edge_line in edges:
intersection_type, intersection_point = line.intersects(edge_line)
if intersection_type == QtCore.QLineF.IntersectionType.BoundedIntersection:
return intersection_point
return rect1.center()
def delete_item(self):
"""删除箭头和箭头头部"""
if self in self.start_item.connections:
self.start_item.connections.remove(self)
if self in self.end_item.connections:
self.end_item.connections.remove(self)
if self.scene_ref: # 确保场景引用存在
self.scene_ref.removeItem(self.arrow_head) # 删除箭头头部
self.scene().removeItem(self) # 删除箭头本身
def contextMenuEvent(self, event):
"""右键菜单"""
menu = QtWidgets.QMenu()
delete_action = menu.addAction("删除")
properties_action = menu.addAction("显示属性")
action = menu.exec(event.screenPos())
if action == delete_action:
self.delete_item()
elif action == properties_action:
self.show_properties()
def show_properties(self):
"""显示箭头属性"""
properties = f"起点: {self.start_item.scenePos().x():.2f}, {self.start_item.scenePos().y():.2f}\n" \
f"终点: {self.end_item.scenePos().x():.2f}, {self.end_item.scenePos().y():.2f}"
QtWidgets.QMessageBox.information(None, "箭头属性", properties)
class GraphicsScene(pg.GraphicsScene):
"""自定义场景"""
def __init__(self):
super().__init__()
self.setBackgroundBrush(QtGui.QColor(240, 240, 240))
self.start_item = None
def start_connection(self, start_item):
"""开始连线"""
self.start_item = start_item
print(f"开始连线:{start_item}")
def mousePressEvent(self, event):
"""鼠标按下事件"""
if self.start_item:
clicked_item = self.itemAt(event.scenePos(), QtGui.QTransform())
if isinstance(clicked_item, (DraggableRect, DraggableEllipse)) and clicked_item != self.start_item:
arrow = ArrowLine(self.start_item, clicked_item)
self.addItem(arrow)
print(f"完成连线:从 {self.start_item} 到 {clicked_item}")
self.start_item = None
super().mousePressEvent(event)
class MainWindow(QtWidgets.QWidget):
"""主窗口"""
def __init__(self):
super().__init__()
self.setWindowTitle("基于 PyQtGraph 的 Visio 风格")
self.setGeometry(100, 100, 1200, 800)
self.scene = GraphicsScene()
self.view = QtWidgets.QGraphicsView(self.scene)
self.view.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
self.scene.setSceneRect(0, 0, 1000, 800)
self.top_bar = QtWidgets.QHBoxLayout()
self.add_tool_button("矩形", self.add_rectangle)
self.add_tool_button("椭圆", self.add_ellipse)
main_layout = QtWidgets.QVBoxLayout()
main_layout.addLayout(self.top_bar)
main_layout.addWidget(self.view)
self.setLayout(main_layout)
def add_tool_button(self, name, callback):
button = QtWidgets.QPushButton(name)
button.setFixedHeight(30)
button.clicked.connect(callback)
self.top_bar.addWidget(button)
def add_rectangle(self):
rect = DraggableRect(-50, -50, 100, 100, QtGui.QColor(100, 200, 255, 150))
rect.setPos(200, 200)
rect.initial_pos = rect.scenePos()
self.scene.addItem(rect)
def add_ellipse(self):
ellipse = DraggableEllipse(-50, -50, 100, 100, QtGui.QColor(200, 100, 255, 150))
ellipse.setPos(400, 200)
ellipse.initial_pos = ellipse.scenePos()
self.scene.addItem(ellipse)
if __name__ == "__main__":
app = pg.mkQApp("PyQtGraph Visio 风格")
window = MainWindow()
window.show()
pg.exec()
六、成品展示
添加、连线
连线时只需点击需要连线的第二个图形就行不需要鼠标拖动,鼠标可以任意拖行图形
属性
删除
七、经验总结
-
模块化设计的优势:
- 将图形、箭头、场景分离,实现独立功能,便于调试和扩展。
-
调试过程的教训:
- 引用问题(如
scene_ref
为None
)在动态场景中非常常见,务必确保每次创建时正确存储引用。 - 清理列表时,应确保不遗漏或重复操作,避免引发错误。
- 引用问题(如
八、未来扩展方向
- 支持更多图形:添加圆形、多边形等。
- 保存与加载功能:支持导出和导入当前的图形布局。
- UI 美化:优化界面布局,添加更多工具栏按钮。