chartflow 0.1.2__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {chartflow-0.1.2 → chartflow-0.1.5}/PKG-INFO +1 -1
  2. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/flow/chart.py +2 -1
  3. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/__init__.py +9 -0
  4. chartflow-0.1.5/chartflow/node/_connection_interface.py +196 -0
  5. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/_node_base.py +29 -2
  6. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/_node_interface.py +3 -1
  7. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/_node_ports.py +99 -30
  8. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/connection_item.py +1877 -1413
  9. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/enums.py +1 -0
  10. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/example.py +45 -6
  11. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/node_canvas.py +118 -11
  12. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/node_item.py +59 -3
  13. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/popmenu.py +53 -3
  14. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/port_item.py +24 -3
  15. chartflow-0.1.5/chartflow/node/qconnection_editor.py +280 -0
  16. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/test_qnode.py +516 -4
  17. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow.egg-info/PKG-INFO +1 -1
  18. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow.egg-info/SOURCES.txt +2 -0
  19. {chartflow-0.1.2 → chartflow-0.1.5}/setup.py +1 -1
  20. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/__init__.py +0 -0
  21. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/_verify_importexport.py +0 -0
  22. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/__init__.py +0 -0
  23. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/code_editor.py +0 -0
  24. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/code_folder.py +0 -0
  25. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/completer.py +0 -0
  26. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/context_analyzer.py +0 -0
  27. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/demo.py +0 -0
  28. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/line_number_area.py +0 -0
  29. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/minimap.py +0 -0
  30. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/python_editor.py +0 -0
  31. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/qcompletion.py +0 -0
  32. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/syntax_highlighter.py +0 -0
  33. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/edit/test_editor.py +0 -0
  34. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/flow/__init__.py +0 -0
  35. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/flow/_demo.py +0 -0
  36. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/flow/editor.py +0 -0
  37. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/flow/flow.py +0 -0
  38. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/flow/qchart.py +0 -0
  39. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/flow/test/__init__.py +0 -0
  40. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/flow/test/test_chart.py +0 -0
  41. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/flow/test_decorator_sync.py +0 -0
  42. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/__init__.py +0 -0
  43. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/decorators.py +0 -0
  44. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/demo.py +0 -0
  45. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/demo_pyqt6.py +0 -0
  46. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/demo_tree.py +0 -0
  47. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/logic.py +0 -0
  48. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/meta.py +0 -0
  49. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/scheduler.py +0 -0
  50. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/stage.py +0 -0
  51. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/stdio.py +0 -0
  52. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test/__init__.py +0 -0
  53. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test/test_decorators.py +0 -0
  54. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test/test_events.py +0 -0
  55. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test/test_integration.py +0 -0
  56. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test/test_logic.py +0 -0
  57. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test/test_meta.py +0 -0
  58. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test/test_runtime_param.py +0 -0
  59. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test/test_scheduler.py +0 -0
  60. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test/test_stage.py +0 -0
  61. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test_behavior_events.py +0 -0
  62. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test_nested_parallel.py +0 -0
  63. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/test_stage_spec.py +0 -0
  64. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/fsm/utils.py +0 -0
  65. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/_node_interaction.py +0 -0
  66. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/_node_markers.py +0 -0
  67. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/_port_interface.py +0 -0
  68. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/arrow_item.py +0 -0
  69. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/color_utils.py +0 -0
  70. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/demo.py +0 -0
  71. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/demo_arrow_drag.py +0 -0
  72. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/demo_decorator.py +0 -0
  73. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/demo_ports.py +0 -0
  74. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/layout_utils.py +0 -0
  75. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/main_window.py +0 -0
  76. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/menu_bar.py +0 -0
  77. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/qnode_editor.py +0 -0
  78. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/selection_rect.py +0 -0
  79. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/style_panel.py +0 -0
  80. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/node/styles.py +0 -0
  81. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/showcase.py +0 -0
  82. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow/theme.py +0 -0
  83. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow.egg-info/dependency_links.txt +0 -0
  84. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow.egg-info/requires.txt +0 -0
  85. {chartflow-0.1.2 → chartflow-0.1.5}/chartflow.egg-info/top_level.txt +0 -0
  86. {chartflow-0.1.2 → chartflow-0.1.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartflow
3
- Version: 0.1.2
3
+ Version: 0.1.5
4
4
  Summary: Self-use. with fsm. node. chart. edit. Used for fsm and run, structure graph. use pyqt to show and interact
5
5
  Author-email: 2229066748@qq.com
6
6
  Maintainer: Eagle'sBaby
@@ -958,7 +958,8 @@ class Chart(IChart):
958
958
  shape_map = {
959
959
  "ellipse": NodeShape.ELLIPSE,
960
960
  "rectangle": NodeShape.RECTANGLE,
961
- "diamond": NodeShape.DIAMOND
961
+ "diamond": NodeShape.DIAMOND,
962
+ "hexagon": NodeShape.HEXAGON
962
963
  }
963
964
 
964
965
  # Create node mapping
@@ -19,12 +19,16 @@ from chartflow.node.node_item import QNodeItem
19
19
  from chartflow.node.port_item import QNodePort, QNodePortSpec
20
20
  from chartflow.node._port_interface import IQNodePort
21
21
  from chartflow.node.connection_item import ConnectionItem
22
+ from chartflow.node._connection_interface import IConnectionItem, IQConnectionEditor
22
23
  from chartflow.node.arrow_item import ArrowItem
23
24
  from chartflow.node.selection_rect import SelectionRect
24
25
  from chartflow.node.style_panel import StylePanel
25
26
  from chartflow.node.node_canvas import QNodeCanvas
26
27
  from chartflow.node.main_window import NodeCanvasWindow
27
28
  from chartflow.node.qnode_editor import QNodeEditor
29
+ from chartflow.node.qconnection_editor import (
30
+ QConnectionEditor, QConnectionNameEditor, openConnectionEditor,
31
+ )
28
32
  from chartflow.node.popmenu import (
29
33
  ICanvasPopMenu, QCanvasPopMenu,
30
34
  INodePopMenu, QNodePopMenu,
@@ -50,6 +54,7 @@ __all__ = [
50
54
  "QNodePortSpec",
51
55
  "IQNodePort",
52
56
  "ConnectionItem",
57
+ "IConnectionItem",
53
58
  "ArrowItem",
54
59
  "SelectionRect",
55
60
  # UI Components
@@ -59,6 +64,10 @@ __all__ = [
59
64
  "NodeCanvasWindow",
60
65
  # Editor
61
66
  "QNodeEditor",
67
+ "QConnectionEditor",
68
+ "QConnectionNameEditor",
69
+ "IQConnectionEditor",
70
+ "openConnectionEditor",
62
71
  # Pop Menus
63
72
  "ICanvasPopMenu", "QCanvasPopMenu",
64
73
  "INodePopMenu", "QNodePopMenu",
@@ -0,0 +1,196 @@
1
+ """
2
+ 连接项接口契约模块
3
+
4
+ 定义 IConnectionItem(Protocol) 与 IQConnectionEditor(Protocol) ——
5
+ 连接项(连线)及其编辑器的对外类型契约。
6
+
7
+ Protocol 仅用于静态类型检查与文档, 不参与运行时继承(不进入 MRO),
8
+ 因此可安全地与 QGraphicsPathItem / QWidget 继承体系并存。
9
+
10
+ IConnectionItem 声明了 ConnectionItem 对外暴露的名称/数据/编辑器/
11
+ 信号/序列化接口; IQConnectionEditor 声明了连线编辑器(见
12
+ qconnection_editor.QConnectionEditor)的对外接口, 与节点编辑器
13
+ QNodeEditor 的接口形态保持一致。
14
+
15
+ 约定: 本模块只含类型声明, 不得有运行时依赖, 以避免循环导入。
16
+ """
17
+
18
+ from typing import Any, Dict, Optional, Protocol, runtime_checkable
19
+
20
+ from PyQt6.QtCore import QPointF, pyqtSignal
21
+
22
+
23
+ @runtime_checkable
24
+ class IConnectionItem(Protocol):
25
+ """连接项接口契约
26
+
27
+ 声明一条连线(ConnectionItem)在 名称 / 编辑器 / 序列化 方面应具备的
28
+ 对外属性与方法。几何/样式/箭头/端口锚定等既有接口不在此重复声明。
29
+
30
+ 契约分组:
31
+
32
+ - **名称与数据**: ``name`` / ``data``
33
+ - **编辑器**: ``editorClass`` / ``editorEmbedded`` / ``isEditWindowOpen`` /
34
+ :meth:`setEditWindowOpen` / :meth:`closeEditor`
35
+ - **几何**: ``center``(连线中点, 编辑器/名称 label 的锚点)
36
+ - **信号**: ``editRequested`` / ``nameChanged``
37
+ - **序列化**: :meth:`toDict` / :meth:`fromDict`
38
+
39
+ 所有坐标默认为 **场景坐标(scene coordinate)**, 几何单位为像素。
40
+ """
41
+
42
+ # ---- 名称与数据 ----
43
+ @property
44
+ def name(self) -> str:
45
+ """连线名称(可读写)。
46
+
47
+ 读取返回当前名称(可为空串); 赋值会更新连线中点的名称 label
48
+ (空串时隐藏)并发出 :attr:`nameChanged` 信号。名称会被
49
+ :meth:`toDict` / :meth:`fromDict` 序列化。
50
+ """
51
+ ...
52
+
53
+ @name.setter
54
+ def name(self, value: str) -> None: ...
55
+
56
+ @property
57
+ def data(self) -> Dict[str, Any]:
58
+ """连线自定义数据字典(只读引用, 内容可改)。
59
+
60
+ 返回内部 ``_data`` 字典本身而非副本, 调用方可直接增删键值,
61
+ 供自定义编辑器(如保存备注/参数)持久化内容使用。
62
+ 会被 :meth:`toDict` / :meth:`fromDict` 序列化。
63
+ """
64
+ ...
65
+
66
+ # ---- 编辑器 ----
67
+ @property
68
+ def editorClass(self) -> Optional[type]:
69
+ """连线编辑器类(可读写)。
70
+
71
+ 设为某个 ``QWidget`` 子类 ``type`` 时, 双击连线(或右键菜单
72
+ Edit Connection)会通过 :attr:`editRequested` 信号请求由该类创建
73
+ 编辑面板; ``None`` 表示使用内置默认名称编辑器
74
+ (:class:`~chartflow.node.qconnection_editor.QConnectionNameEditor`)。
75
+ 编辑器类本身不可序列化(与节点 editorClass 一致)。
76
+ """
77
+ ...
78
+
79
+ @editorClass.setter
80
+ def editorClass(self, value: Optional[type]) -> None: ...
81
+
82
+ @property
83
+ def editorEmbedded(self) -> bool:
84
+ """编辑器是否嵌入连线中点(可读写, 默认 ``True``)。
85
+
86
+ - ``True``: 编辑器经 ``QGraphicsProxyWidget`` 嵌入连线中点, 随连线
87
+ 路径更新自动跟随;
88
+ - ``False``: 编辑器作为独立浮动窗口打开(与节点编辑器一致)。
89
+
90
+ 该状态会被 :meth:`toDict` / :meth:`fromDict` 序列化(仅在
91
+ ``False`` 时落盘, 避免污染默认导出)。
92
+ """
93
+ ...
94
+
95
+ @editorEmbedded.setter
96
+ def editorEmbedded(self, value: bool) -> None: ...
97
+
98
+ @property
99
+ def isEditWindowOpen(self) -> bool:
100
+ """当前是否有编辑器窗口/嵌入面板打开(只读)。"""
101
+ ...
102
+
103
+ def setEditWindowOpen(self, open: bool) -> None:
104
+ """切换编辑窗口打开状态, 进而控制中点名称 label 的高亮提示。
105
+
106
+ 由编辑器(如 :class:`QConnectionEditor`)在 show/close 时自动驱动;
107
+ 打开时名称 label 显示高亮底衬(类比节点的 📖 悬浮标记)。
108
+
109
+ Args:
110
+ open: ``True`` 表示编辑器已打开, ``False`` 已关闭。
111
+ """
112
+ ...
113
+
114
+ def closeEditor(self) -> None:
115
+ """关闭当前打开的编辑器并清理嵌入代理(无打开编辑器时为空操作)。
116
+
117
+ 画布在删除连线(:meth:`NodeCanvas.removeConnection`)时调用,
118
+ 防止编辑器窗口/嵌入代理悬挂。
119
+ """
120
+ ...
121
+
122
+ # ---- 几何 ----
123
+ @property
124
+ def center(self) -> QPointF:
125
+ """连线中点的 **场景坐标**(只读), 取自 ``path().pointAtPercent(0.5)``。
126
+
127
+ 作为名称 label 与嵌入编辑器的锚点; 自环连线同样按路径 50% 处取值。
128
+ """
129
+ ...
130
+
131
+ # ---- 信号(只读属性, 返回 pyqtSignal) ----
132
+ @property
133
+ def editRequested(self) -> pyqtSignal:
134
+ """编辑请求信号(只读)。
135
+
136
+ 双击连线或点击右键菜单 Edit Connection 时发射, 载荷为本连线实例;
137
+ 画布默认连接到打开编辑器的处理器(无 editorClass 时打开内置名称
138
+ 编辑器), 宿主可自行断开/接管。
139
+ """
140
+ ...
141
+
142
+ @property
143
+ def nameChanged(self) -> pyqtSignal:
144
+ """名称变更信号(只读)。名称被修改时发射, 载荷为 (连线, 新名称)。"""
145
+ ...
146
+
147
+ # ---- 序列化 ----
148
+ def toDict(self) -> dict:
149
+ """将连线的 名称/数据/编辑器布局 序列化为字典。
150
+
151
+ 仅包含非默认键: ``name``(非空) / ``data``(非空) /
152
+ ``editor_embedded``(仅 ``False``)。拓扑(source/target/port)与样式
153
+ 由画布 export 与 styleToDict 负责, 不在此重复。
154
+
155
+ Returns:
156
+ 连线数据字典(深拷贝, 修改返回值不影响连线)。
157
+ """
158
+ ...
159
+
160
+ def fromDict(self, data: dict) -> None:
161
+ """从字典恢复连线的 名称/数据/编辑器布局(增量合并, 忽略未知键)。
162
+
163
+ Args:
164
+ data: 由 :meth:`toDict` 产生(或同构)的字典。
165
+ """
166
+ ...
167
+
168
+
169
+ @runtime_checkable
170
+ class IQConnectionEditor(Protocol):
171
+ """连线编辑器接口契约
172
+
173
+ 声明连线编辑器应具备的对外接口, 与节点编辑器
174
+ :class:`~chartflow.node.qnode_editor.QNodeEditor` 形态保持一致:
175
+ 构造时传入被编辑对象, 提供 ``getTitle`` / ``save`` / ``load``。
176
+
177
+ 具体实现由 :class:`~chartflow.node.qconnection_editor.QConnectionEditor`
178
+ 承担; 用户自定义编辑器继承该类(或满足本契约)即可。
179
+ """
180
+
181
+ @property
182
+ def connection(self) -> IConnectionItem:
183
+ """获取当前编辑的连线(只读)。"""
184
+ ...
185
+
186
+ def getTitle(self) -> str:
187
+ """获取编辑器窗口标题(嵌入模式下一般不可见)。"""
188
+ ...
189
+
190
+ def save(self) -> bool:
191
+ """保存编辑器内容到连线。返回是否成功。"""
192
+ ...
193
+
194
+ def load(self) -> bool:
195
+ """从连线加载内容到编辑器。返回是否成功。"""
196
+ ...
@@ -398,7 +398,10 @@ class _NodeCore(QGraphicsEllipseItem):
398
398
 
399
399
  - 名称需容纳在内部区域(INNER_RATIO 以内); 有 hit 时额外预留
400
400
  (dot 深度 + 间距 + hit 文本宽度), 避免 hit 与名称重叠。
401
- - 矩形且有端口时: 宽度由名称(+hit)决定, 高度由最多端口的一侧决定(可长宽不等)。
401
+ - 矩形且有端口时: 宽度由名称(+hit)决定, 高度由最多端口的一侧决定
402
+ (可长宽不等, 但宽不短于高——顶/底边不短于左右边)。
403
+ - 六边形: 平顶/平底边长(=rw)须不短于斜边长 hypot(rw/2, rh),
404
+ 即 rw >= rh * 2/√3, 按此加宽(名称短时正方形不满足该约束)。
402
405
  - 其余(椭圆/菱形/无端口矩形): 保持方形。
403
406
  """
404
407
  tw, th = self._nameTextSize()
@@ -411,6 +414,8 @@ class _NodeCore(QGraphicsEllipseItem):
411
414
  port_extent = self._portInwardExtent()
412
415
  rw = max(rw_name, name_half + port_extent + NAME_INNER_PAD, self._min_radius)
413
416
  rh = max(rh_name, self._halfHeightForPorts(rw), self._min_radius)
417
+ # 矩形: 顶/底边(宽)不短于左右边(高); 名称短/端口多导致超高时加宽
418
+ rw = max(rw, rh)
414
419
  return rw, rh
415
420
 
416
421
  # 方形
@@ -421,6 +426,10 @@ class _NodeCore(QGraphicsEllipseItem):
421
426
  R = max(R, self._circlePortRadius())
422
427
  else:
423
428
  R = max(R, self._halfHeightForPorts(R))
429
+ if self._shape == NodeShape.HEXAGON:
430
+ # 六边形: 顶/底边 >= 斜边 -> rw >= rh * 2/√3; 加宽而非压扁,
431
+ # 以保留端口/名称的纵向空间
432
+ return R * 2.0 / math.sqrt(3.0), R
424
433
  return R, R
425
434
 
426
435
  def _setSize(self, rw: float, rh: float) -> None:
@@ -429,6 +438,12 @@ class _NodeCore(QGraphicsEllipseItem):
429
438
  # 圆形节点固定为标准圆(宽高相等), 保证端口沿弧线布局正确
430
439
  side = max(rw, rh)
431
440
  rw = rh = side
441
+ elif self._shape == NodeShape.HEXAGON:
442
+ # 六边形: 顶/底边 >= 斜边 -> rw >= rh * 2/√3(兜底, 含自定义半径路径)
443
+ rw = max(rw, rh * 2.0 / math.sqrt(3.0))
444
+ elif self._shape == NodeShape.RECTANGLE:
445
+ # 矩形: 顶/底边(宽)不短于左右边(高)(兜底)
446
+ rw = max(rw, rh)
432
447
  self._rw = rw
433
448
  self._rh = rh
434
449
  self._radius = rw # 兼容椭圆/菱形及旧代码
@@ -475,6 +490,7 @@ class _NodeCore(QGraphicsEllipseItem):
475
490
  - ELLIPSE: 圆(半径 + 外环)
476
491
  - RECTANGLE: 圆角矩形(外扩外环)
477
492
  - DIAMOND: 菱形(四顶点外扩外环)
493
+ - HEXAGON: 六边形(六顶点外扩外环)
478
494
  """
479
495
  rect = self.rect()
480
496
  m = self._hitShapeMargin()
@@ -488,7 +504,7 @@ class _NodeCore(QGraphicsEllipseItem):
488
504
  # 圆角半径与 paint() 一致(基于原始 rect, 否则圆角位置错位)
489
505
  corner = min(rect.width(), rect.height()) * 0.2
490
506
  path.addRoundedRect(outer, corner, corner)
491
- else: # DIAMOND
507
+ elif self._shape == NodeShape.DIAMOND:
492
508
  c = rect.center()
493
509
  hw = rect.width() / 2.0 + m
494
510
  hh = rect.height() / 2.0 + m
@@ -497,6 +513,17 @@ class _NodeCore(QGraphicsEllipseItem):
497
513
  path.lineTo(c.x(), c.y() + hh)
498
514
  path.lineTo(c.x() - hw, c.y())
499
515
  path.closeSubpath()
516
+ else: # HEXAGON
517
+ c = rect.center()
518
+ hw = rect.width() / 2.0 + m
519
+ hh = rect.height() / 2.0 + m
520
+ path.moveTo(c.x() - hw / 2.0, c.y() - hh)
521
+ path.lineTo(c.x() + hw / 2.0, c.y() - hh)
522
+ path.lineTo(c.x() + hw, c.y())
523
+ path.lineTo(c.x() + hw / 2.0, c.y() + hh)
524
+ path.lineTo(c.x() - hw / 2.0, c.y() + hh)
525
+ path.lineTo(c.x() - hw, c.y())
526
+ path.closeSubpath()
500
527
  return path
501
528
 
502
529
  def _centerText(self) -> None:
@@ -78,6 +78,7 @@ class INodeItem(Protocol):
78
78
  - ``ELLIPSE`` 椭圆/圆形(强制为宽高相等的标准圆)
79
79
  - ``RECTANGLE`` 圆角矩形
80
80
  - ``DIAMOND`` 菱形
81
+ - ``HEXAGON`` 六边形(平顶/平底, 左右尖角)
81
82
 
82
83
  赋值后若未设置自定义半径, 会按新形状重算尺寸; 切换到圆形时
83
84
  端口会从轴向排布切换为沿侧弧径向排布。
@@ -396,7 +397,8 @@ class INodeItem(Protocol):
396
397
  """计算从节点中心指向 ``target_pos`` 方向、落在节点外轮廓上的交点。
397
398
 
398
399
  依据当前 :attr:`nodeShape` 采用不同算法: 圆形用三角函数、
399
- 圆角矩形先求外接矩形交点再修正圆角、菱形用 L1 范数缩放。
400
+ 圆角矩形先求外接矩形交点再修正圆角、菱形用 L1 范数缩放、
401
+ 六边形取平顶边与斜边两组约束的较小射线交点。
400
402
 
401
403
  Args:
402
404
  target_pos: 目标位置(场景坐标)。
@@ -56,6 +56,11 @@ class _NodePortMixin:
56
56
  self._port_label_timer.setSingleShot(True)
57
57
  self._port_label_timer.setInterval(PORT_LABEL_HOVER_DELAY_MS)
58
58
  self._port_label_timer.timeout.connect(self._showAllPortLabels)
59
+ # label 钉住态(始终可见)与闪现计时器(flashPortLabels 用)
60
+ self._port_labels_pinned: bool = False
61
+ self._port_flash_timer: QTimer = QTimer()
62
+ self._port_flash_timer.setSingleShot(True)
63
+ self._port_flash_timer.timeout.connect(self._unflashPortLabels)
59
64
 
60
65
  # ------------------------------------------------------------------ #
61
66
  # ports 属性
@@ -126,6 +131,50 @@ class _NodePortMixin:
126
131
  result[key] = ports
127
132
  return result
128
133
 
134
+ # ------------------------------------------------------------------ #
135
+ # 端口 label 显隐(钉住 / 闪现)
136
+ # ------------------------------------------------------------------ #
137
+ @property
138
+ def portLabelsVisible(self) -> bool:
139
+ """端口 label 是否始终可见(读写)。
140
+
141
+ True 时全部端口 label 钉住常显(优先于 hover 规则); False 时交还
142
+ hover 规则(节点 hover>0.5s 显示全部 / hover 单个 dot 显示该条)。
143
+ 对重建端口(ports 重赋值)后新建的端口项同样生效。
144
+ """
145
+ return self._port_labels_pinned
146
+
147
+ @portLabelsVisible.setter
148
+ def portLabelsVisible(self, on: bool) -> None:
149
+ self._port_labels_pinned = bool(on)
150
+ self._setAllPortsLabelPinned(on)
151
+
152
+ def _setAllPortsLabelPinned(self, on: bool) -> None:
153
+ for side in ("left", "right"):
154
+ for item in self._port_items[side]:
155
+ item.setLabelPinned(on)
156
+
157
+ def flashPortLabels(self, continue_time: int = 1500) -> None:
158
+ """模拟弹出全部端口 label 并持续 ``continue_time`` 毫秒, 到期自动收回。
159
+
160
+ 重复调用会重新计时。与 hover 规则正交: 闪现收回后 label 回到
161
+ hover/钉住所决定的可见态。continue_time <= 0 时立即收回。
162
+ """
163
+ for side in ("left", "right"):
164
+ for item in self._port_items[side]:
165
+ item.setLabelFlashed(True)
166
+ if continue_time <= 0:
167
+ self._unflashPortLabels()
168
+ return
169
+ self._port_flash_timer.start(max(0, int(continue_time)))
170
+
171
+ def _unflashPortLabels(self) -> None:
172
+ """闪现到期: 解除全部端口的闪现态(label 回到 hover/钉住规则)。"""
173
+ self._port_flash_timer.stop()
174
+ for side in ("left", "right"):
175
+ for item in self._port_items[side]:
176
+ item.setLabelFlashed(False)
177
+
129
178
  # ------------------------------------------------------------------ #
130
179
  # 端口渲染项构建/移除
131
180
  # ------------------------------------------------------------------ #
@@ -142,6 +191,7 @@ class _NodePortMixin:
142
191
  for side in ("left", "right"):
143
192
  for spec in self._ports_spec.get(side, []):
144
193
  item = QNodePort(spec, side, self)
194
+ item.setLabelPinned(self._port_labels_pinned) # 继承钉住态
145
195
  self._port_items[side].append(item)
146
196
  self._layoutPorts()
147
197
 
@@ -163,21 +213,22 @@ class _NodePortMixin:
163
213
  # 端口布局
164
214
  # ------------------------------------------------------------------ #
165
215
  def _layoutPorts(self) -> None:
166
- """分布端口: 圆形沿侧弧径向; 菱形沿左/右斜边贴合排布; 矩形沿边缘轴向。"""
216
+ """分布端口: 圆形沿侧弧径向; 菱形/六边形沿左/右斜边贴合排布; 矩形沿边缘轴向。"""
167
217
  is_circle = self._shape == NodeShape.ELLIPSE
168
- is_diamond = self._shape == NodeShape.DIAMOND
218
+ # 菱形与六边形(左右均为两条斜边)共用斜边贴合布局
219
+ is_slant = self._shape in (NodeShape.DIAMOND, NodeShape.HEXAGON)
169
220
  for side in ("left", "right"):
170
221
  for it in self._port_items[side]:
171
- # 互斥的排布模式: _radial(圆形) / _diamond(菱形) / 皆 False(矩形)
222
+ # 互斥的排布模式: _radial(圆形) / _diamond(斜边) / 皆 False(矩形)
172
223
  if it._radial != is_circle:
173
224
  it._radial = is_circle
174
225
  it.update()
175
- if it._diamond != is_diamond:
176
- it._diamond = is_diamond
226
+ if it._diamond != is_slant:
227
+ it._diamond = is_slant
177
228
  it.update()
178
229
  if is_circle:
179
230
  self._layoutPortsOnCircle()
180
- elif is_diamond:
231
+ elif is_slant:
181
232
  self._layoutPortsOnDiamond()
182
233
  else:
183
234
  self._layoutPortsAxisAligned()
@@ -227,16 +278,29 @@ class _NodePortMixin:
227
278
  it.setPos(QPointF(x, y))
228
279
  it.update()
229
280
 
281
+ def _slantInsetX(self) -> float:
282
+ """斜边上端点(顶/底侧)相对中心 x 的内缩量。
283
+
284
+ 菱形: 0(斜边直达中线上的顶/底尖角);
285
+ 六边形: half_w / 2(斜边从侧尖角连到平顶/平底边的端点)。
286
+ """
287
+ if self._shape == NodeShape.HEXAGON:
288
+ return self.rect().width() / 4.0
289
+ return 0.0
290
+
230
291
  def _layoutPortsOnDiamond(self) -> None:
231
- """菱形: 端口沿左/右两条斜边贴合排布, dot 旋转使底边贴合斜边、
292
+ """菱形/六边形: 端口沿左/右两条斜边贴合排布, dot 旋转使底边贴合斜边、
232
293
  apex 沿斜边内法线(指向节点内部), 出射方向沿斜边外法线。
233
294
 
234
295
  每侧(right/left)对应两条斜边(上、下), 总弧长 2L(L=单条斜边长)。
235
296
  端口沿弧长以 PORT_MIN_GAP 紧凑排布, 整组围绕侧顶点(右顶点/左顶点,
236
297
  即赤道)居中。**所有四个顶点(top/side/bottom 及对侧顶点)附近都预留
237
- 顶点间隙**, 故端口自然落在斜边中段, 不压在任何菱角上。
298
+ 顶点间隙**, 故端口自然落在斜边中段, 不压在任何尖角上。
299
+
300
+ 斜边端点由 _slantInsetX() 参数化: 菱形斜边连到中线的顶/底尖角,
301
+ 六边形斜边连到平顶/平底边的端点(x=±hw/2), 布局算法完全一致。
238
302
 
239
- 注意: 菱形斜边的内法线是**该斜边的常量法线**(垂直于斜边), 而非"指向
303
+ 注意: 斜边的内法线是**该斜边的常量法线**(垂直于斜边), 而非"指向
240
304
  圆心"的方向 —— 二者仅在斜边中点重合。dot 底边须平行于斜边, 故
241
305
  _orient_angle 必须取斜边内法线方向(使旋转后底边贴合斜边)。
242
306
  """
@@ -246,20 +310,22 @@ class _NodePortMixin:
246
310
  hh = rect.height() / 2.0
247
311
  if hw <= 0 or hh <= 0:
248
312
  return
249
- L = math.hypot(hw, hh) # 单条斜边长
250
- # 斜边内法线(指向节点内部, 垂直于斜边)。菱形四条斜边, 内法线方向固定。
251
- # 右上(TR: top->right) 斜边方向 (hw, hh), 内法线 (-hh, hw)
252
- # 右下(RB: right->bottom) 方向 (-hw, hh), 内法线 (-hh, -hw)
253
- # 左下(BL: bottom->left) 方向 (-hw, -hh), 内法线 (hh, -hw)
254
- # 左上(LT: left->top) 方向 (hw, -hh), 内法线 (hh, hw)
313
+ kx = self._slantInsetX()
314
+ sw = hw - kx # 斜边的水平跨度(菱形=hw, 六边形=hw/2)
315
+ L = math.hypot(sw, hh) # 单条斜边长
316
+ # 斜边内法线(指向节点内部, 垂直于斜边)。四条斜边, 内法线方向固定。
317
+ # 右上(TR: top->right) 斜边方向 (sw, hh), 内法线 (-hh, sw)
318
+ # 右下(RB: right->bottom) 方向 (-sw, hh), 内法线 (-hh, -sw)
319
+ # 左下(BL: bottom->left) 方向 (-sw, -hh), 内法线 (hh, -sw)
320
+ # 左上(LT: left->top) 方向 (sw, -hh), 内法线 (hh, sw)
255
321
  def inward_normal(ix, iy):
256
322
  nl = math.hypot(ix, iy)
257
323
  return (ix / nl, iy / nl) if nl > 1e-6 else (0.0, 0.0)
258
324
 
259
- NORM_TR = inward_normal(-hh, hw) # 右上斜边内法线
260
- NORM_RB = inward_normal(-hh, -hw) # 右下斜边内法线
261
- NORM_BL = inward_normal(hh, -hw) # 左下斜边内法线
262
- NORM_LT = inward_normal(hh, hw) # 左上斜边内法线
325
+ NORM_TR = inward_normal(-hh, sw) # 右上斜边内法线
326
+ NORM_RB = inward_normal(-hh, -sw) # 右下斜边内法线
327
+ NORM_BL = inward_normal(hh, -sw) # 左下斜边内法线
328
+ NORM_LT = inward_normal(hh, sw) # 左上斜边内法线
263
329
 
264
330
  # 四个顶点处的弧长预留(像素, 沿弧长方向): 避免端口压在任何菱角上。
265
331
  # top/bottom 顶点处一条斜边交汇, side 顶点处两条斜边交汇, 均需避让。
@@ -316,30 +382,31 @@ class _NodePortMixin:
316
382
  s_values.append(L + vertex_gap + off_c) # 下半中段内
317
383
  for it, s in zip(items, s_values):
318
384
  # s ∈ [0, 2L]; 上半斜边 s∈[0,L](top→侧顶点), 下半 s∈[L,2L](侧顶点→bottom)
385
+ # 斜边端点: 侧尖角(±hw,0) <-> 顶/底端点(±kx,∓hh); kx=0 即菱形
319
386
  if side == "right":
320
387
  if s <= L:
321
- # 上半(TR): top(0,-hh) -> right(+hw,0), t=s/L
388
+ # 上半(TR): (kx,-hh) -> right(+hw,0), t=s/L
322
389
  t = max(0.0, min(1.0, s / L))
323
- px = cx + hw * t
390
+ px = cx + kx + sw * t
324
391
  py = cy + (-hh) + hh * t
325
392
  nx, ny = NORM_TR
326
393
  else:
327
- # 下半(RB): right(+hw,0) -> bottom(0,+hh), t=(s-L)/L
394
+ # 下半(RB): right(+hw,0) -> (kx,+hh), t=(s-L)/L
328
395
  t = max(0.0, min(1.0, (s - L) / L))
329
- px = cx + hw + (-hw) * t
396
+ px = cx + hw - sw * t
330
397
  py = cy + hh * t
331
398
  nx, ny = NORM_RB
332
399
  else: # left
333
400
  if s <= L:
334
- # 上半(LT): top(0,-hh) -> left(-hw,0), t=s/L
401
+ # 上半(LT): (-kx,-hh) -> left(-hw,0), t=s/L
335
402
  t = max(0.0, min(1.0, s / L))
336
- px = cx + (-hw) * t
403
+ px = cx - kx - sw * t
337
404
  py = cy + (-hh) + hh * t
338
405
  nx, ny = NORM_LT
339
406
  else:
340
- # 下半(BL): left(-hw,0) -> bottom(0,+hh), t=(s-L)/L
407
+ # 下半(BL): left(-hw,0) -> (-kx,+hh), t=(s-L)/L
341
408
  t = max(0.0, min(1.0, (s - L) / L))
342
- px = cx + (-hw) + hw * t
409
+ px = cx - hw + sw * t
343
410
  py = cy + hh * t
344
411
  nx, ny = NORM_BL
345
412
  it._orient_angle = math.degrees(math.atan2(ny, nx))
@@ -347,15 +414,15 @@ class _NodePortMixin:
347
414
  it.update()
348
415
 
349
416
  def _diamondSideBias(self) -> float:
350
- """菱形每条斜边上端口组的"逻辑中点"参数(0=远端顶点, 1=侧顶点)。
417
+ """菱形/六边形每条斜边上端口组的"逻辑中点"参数(0=远端顶点, 1=侧顶点)。
351
418
 
352
419
  取 0.7: 端口组偏向侧顶点(左右两角), 体现"左右两侧"的视觉聚类,
353
- 同时仍处于斜边中段(避开顶点预留区), 不会压在任何菱角上。
420
+ 同时仍处于斜边中段(避开顶点预留区), 不会压在任何尖角上。
354
421
  """
355
422
  return 0.7
356
423
 
357
424
  def _diamondVertexGap(self) -> float:
358
- """菱形每个顶点处沿弧长预留的间隙(像素)。
425
+ """菱形/六边形每个顶点处沿弧长预留的间隙(像素)。
359
426
 
360
427
  四个顶点(top/底/左/右)处斜边交汇, 法线方向不连续, 端口压在顶点上
361
428
  视觉突兀。预留固定像素间隙使端口落在斜边中段。取 10px: 对常见尺寸
@@ -372,6 +439,8 @@ class _NodePortMixin:
372
439
  return corner + 4.0
373
440
  if self._shape == NodeShape.DIAMOND:
374
441
  return half_h * 0.30
442
+ if self._shape == NodeShape.HEXAGON:
443
+ return half_h * 0.25
375
444
  # ELLIPSE
376
445
  return half_h * 0.22
377
446