IncludeCPP 3.7.3__py3-none-any.whl
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.
- includecpp/__init__.py +59 -0
- includecpp/__init__.pyi +255 -0
- includecpp/__main__.py +4 -0
- includecpp/cli/__init__.py +4 -0
- includecpp/cli/commands.py +8270 -0
- includecpp/cli/config_parser.py +127 -0
- includecpp/core/__init__.py +19 -0
- includecpp/core/ai_integration.py +2132 -0
- includecpp/core/build_manager.py +2416 -0
- includecpp/core/cpp_api.py +376 -0
- includecpp/core/cpp_api.pyi +95 -0
- includecpp/core/cppy_converter.py +3448 -0
- includecpp/core/cssl/CSSL_DOCUMENTATION.md +2075 -0
- includecpp/core/cssl/__init__.py +42 -0
- includecpp/core/cssl/cssl_builtins.py +2271 -0
- includecpp/core/cssl/cssl_builtins.pyi +1393 -0
- includecpp/core/cssl/cssl_events.py +621 -0
- includecpp/core/cssl/cssl_modules.py +2803 -0
- includecpp/core/cssl/cssl_parser.py +2575 -0
- includecpp/core/cssl/cssl_runtime.py +3051 -0
- includecpp/core/cssl/cssl_syntax.py +488 -0
- includecpp/core/cssl/cssl_types.py +1512 -0
- includecpp/core/cssl_bridge.py +882 -0
- includecpp/core/cssl_bridge.pyi +488 -0
- includecpp/core/error_catalog.py +802 -0
- includecpp/core/error_formatter.py +1016 -0
- includecpp/core/exceptions.py +97 -0
- includecpp/core/path_discovery.py +77 -0
- includecpp/core/project_ui.py +3370 -0
- includecpp/core/settings_ui.py +326 -0
- includecpp/generator/__init__.py +1 -0
- includecpp/generator/parser.cpp +1903 -0
- includecpp/generator/parser.h +281 -0
- includecpp/generator/type_resolver.cpp +363 -0
- includecpp/generator/type_resolver.h +68 -0
- includecpp/py.typed +0 -0
- includecpp/templates/cpp.proj.template +18 -0
- includecpp/vscode/__init__.py +1 -0
- includecpp/vscode/cssl/__init__.py +1 -0
- includecpp/vscode/cssl/language-configuration.json +38 -0
- includecpp/vscode/cssl/package.json +50 -0
- includecpp/vscode/cssl/snippets/cssl.snippets.json +1080 -0
- includecpp/vscode/cssl/syntaxes/cssl.tmLanguage.json +341 -0
- includecpp-3.7.3.dist-info/METADATA +1076 -0
- includecpp-3.7.3.dist-info/RECORD +49 -0
- includecpp-3.7.3.dist-info/WHEEL +5 -0
- includecpp-3.7.3.dist-info/entry_points.txt +2 -0
- includecpp-3.7.3.dist-info/licenses/LICENSE +21 -0
- includecpp-3.7.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,3370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IncludeCPP Project Interface with CodeMaker
|
|
3
|
+
Professional visual system design and planning tool
|
|
4
|
+
|
|
5
|
+
Features:
|
|
6
|
+
- Modern dark frameless window with custom controls
|
|
7
|
+
- Interactive node-based mindmap canvas
|
|
8
|
+
- 24+ node types with categorized menus
|
|
9
|
+
- Pan/zoom navigation with smooth animations
|
|
10
|
+
- Multi-selection with rubber band
|
|
11
|
+
- Undo/redo system
|
|
12
|
+
- Copy/paste/duplicate
|
|
13
|
+
- Node grouping
|
|
14
|
+
- Code generation (C++/Python)
|
|
15
|
+
- Template system
|
|
16
|
+
- Search and filter
|
|
17
|
+
- Minimap overview
|
|
18
|
+
- Keyboard shortcuts
|
|
19
|
+
- Export (PNG/SVG/Code)
|
|
20
|
+
- Cross-platform support
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
import json
|
|
25
|
+
import math
|
|
26
|
+
import uuid
|
|
27
|
+
import copy
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional, List, Dict, Tuple, Any, Set, Union
|
|
30
|
+
from dataclasses import dataclass, field, asdict
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from enum import Enum
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from PyQt6.QtWidgets import (
|
|
36
|
+
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
37
|
+
QLabel, QPushButton, QFrame, QSplitter, QTreeWidget, QTreeWidgetItem,
|
|
38
|
+
QGraphicsView, QGraphicsScene, QGraphicsItem, QGraphicsRectItem,
|
|
39
|
+
QGraphicsTextItem, QGraphicsLineItem, QGraphicsEllipseItem,
|
|
40
|
+
QMenu, QInputDialog, QMessageBox, QGraphicsDropShadowEffect,
|
|
41
|
+
QScrollArea, QSizePolicy, QGraphicsPathItem, QToolBar, QStatusBar,
|
|
42
|
+
QGraphicsProxyWidget, QLineEdit, QTextEdit, QDialog, QDialogButtonBox,
|
|
43
|
+
QFormLayout, QComboBox, QSpinBox, QColorDialog, QGraphicsPolygonItem,
|
|
44
|
+
QToolButton, QWidgetAction, QSlider, QProgressBar, QFileDialog,
|
|
45
|
+
QTabWidget, QListWidget, QListWidgetItem, QCheckBox, QGridLayout,
|
|
46
|
+
QGroupBox, QPlainTextEdit, QRubberBand, QStackedWidget, QDockWidget
|
|
47
|
+
)
|
|
48
|
+
from PyQt6.QtCore import (
|
|
49
|
+
Qt, QRectF, QPointF, QTimer, QPropertyAnimation, QEasingCurve,
|
|
50
|
+
pyqtSignal, QObject, QLineF, QSize, QParallelAnimationGroup,
|
|
51
|
+
QSequentialAnimationGroup, pyqtProperty, QVariantAnimation,
|
|
52
|
+
QMimeData, QByteArray, QBuffer, QIODevice, QRect
|
|
53
|
+
)
|
|
54
|
+
from PyQt6.QtGui import (
|
|
55
|
+
QFont, QColor, QPen, QBrush, QPainter, QPainterPath,
|
|
56
|
+
QLinearGradient, QRadialGradient, QCursor, QWheelEvent,
|
|
57
|
+
QMouseEvent, QKeyEvent, QTransform, QPolygonF, QFontMetrics,
|
|
58
|
+
QPalette, QPixmap, QIcon, QAction, QClipboard, QImage,
|
|
59
|
+
QKeySequence, QFontDatabase, QGuiApplication, QUndoStack,
|
|
60
|
+
QUndoCommand, QShortcut
|
|
61
|
+
)
|
|
62
|
+
PYQT_AVAILABLE = True
|
|
63
|
+
except ImportError:
|
|
64
|
+
PYQT_AVAILABLE = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ============================================================================
|
|
68
|
+
# Cross-Platform Support
|
|
69
|
+
# ============================================================================
|
|
70
|
+
|
|
71
|
+
def get_system_font() -> str:
|
|
72
|
+
"""Get platform-appropriate system font."""
|
|
73
|
+
if sys.platform == "win32":
|
|
74
|
+
return "Segoe UI"
|
|
75
|
+
elif sys.platform == "darwin":
|
|
76
|
+
return "SF Pro"
|
|
77
|
+
else:
|
|
78
|
+
return "Ubuntu"
|
|
79
|
+
|
|
80
|
+
def get_monospace_font() -> str:
|
|
81
|
+
"""Get platform-appropriate monospace font."""
|
|
82
|
+
if sys.platform == "win32":
|
|
83
|
+
return "Consolas"
|
|
84
|
+
elif sys.platform == "darwin":
|
|
85
|
+
return "SF Mono"
|
|
86
|
+
else:
|
|
87
|
+
return "Ubuntu Mono"
|
|
88
|
+
|
|
89
|
+
def get_dpi_scale() -> float:
|
|
90
|
+
"""Get DPI scale factor for high-DPI displays."""
|
|
91
|
+
if PYQT_AVAILABLE:
|
|
92
|
+
app = QApplication.instance()
|
|
93
|
+
if app:
|
|
94
|
+
screen = app.primaryScreen()
|
|
95
|
+
if screen:
|
|
96
|
+
return screen.logicalDotsPerInch() / 96.0
|
|
97
|
+
return 1.0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ============================================================================
|
|
101
|
+
# Constants & Theme
|
|
102
|
+
# ============================================================================
|
|
103
|
+
|
|
104
|
+
SYSTEM_FONT = get_system_font() if PYQT_AVAILABLE else "Segoe UI"
|
|
105
|
+
MONO_FONT = get_monospace_font() if PYQT_AVAILABLE else "Consolas"
|
|
106
|
+
|
|
107
|
+
THEME = {
|
|
108
|
+
'bg_dark': '#0d0d0d',
|
|
109
|
+
'bg_primary': '#1a1a1a',
|
|
110
|
+
'bg_secondary': '#252525',
|
|
111
|
+
'bg_tertiary': '#2d2d2d',
|
|
112
|
+
'bg_hover': '#3d3d3d',
|
|
113
|
+
'border': '#3d3d3d',
|
|
114
|
+
'border_light': '#4d4d4d',
|
|
115
|
+
'text_primary': '#e0e0e0',
|
|
116
|
+
'text_secondary': '#a0a0a0',
|
|
117
|
+
'text_muted': '#666666',
|
|
118
|
+
'accent_blue': '#4a9eff',
|
|
119
|
+
'accent_green': '#50c878',
|
|
120
|
+
'accent_orange': '#ff9800',
|
|
121
|
+
'accent_purple': '#e040fb',
|
|
122
|
+
'accent_red': '#ff5555',
|
|
123
|
+
'accent_cyan': '#00bcd4',
|
|
124
|
+
'accent_yellow': '#ffeb3b',
|
|
125
|
+
'accent_pink': '#ff4081',
|
|
126
|
+
'accent_teal': '#009688',
|
|
127
|
+
'accent_indigo': '#3f51b5',
|
|
128
|
+
'accent_lime': '#cddc39',
|
|
129
|
+
'success': '#4caf50',
|
|
130
|
+
'warning': '#ff9800',
|
|
131
|
+
'error': '#f44336',
|
|
132
|
+
'shadow': 'rgba(0, 0, 0, 0.4)',
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Expanded node types with categories
|
|
136
|
+
NODE_CATEGORIES = {
|
|
137
|
+
'Sources': ['source'], # Primary - generates actual code files
|
|
138
|
+
'Code Structures': ['class', 'struct', 'enum', 'interface', 'template_class', 'union'],
|
|
139
|
+
'Functions': ['function', 'method', 'constructor', 'destructor', 'lambda', 'operator'],
|
|
140
|
+
'Data': ['object', 'variable', 'constant', 'definition', 'typedef', 'pointer'],
|
|
141
|
+
'Organization': ['namespace', 'module', 'package', 'file', 'folder', 'header'],
|
|
142
|
+
'Flow': ['condition', 'loop', 'exception', 'async', 'callback', 'event'],
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
NODE_TYPES = {
|
|
146
|
+
# Sources - Primary nodes that generate actual code files
|
|
147
|
+
'source': {'color': '#00e676', 'icon': 'S', 'gradient_start': '#00ff88', 'gradient_end': '#00c853', 'label': 'Source', 'category': 'Sources', 'port_count': 8},
|
|
148
|
+
|
|
149
|
+
# Code Structures
|
|
150
|
+
'class': {'color': '#4a9eff', 'icon': 'C', 'gradient_start': '#5aaeff', 'gradient_end': '#3a8eef', 'label': 'Class', 'category': 'Code Structures'},
|
|
151
|
+
'struct': {'color': '#9c27b0', 'icon': 'S', 'gradient_start': '#ac37c0', 'gradient_end': '#8c17a0', 'label': 'Struct', 'category': 'Code Structures'},
|
|
152
|
+
'enum': {'color': '#ffeb3b', 'icon': 'E', 'gradient_start': '#fff34b', 'gradient_end': '#e0cc2b', 'label': 'Enum', 'category': 'Code Structures'},
|
|
153
|
+
'interface': {'color': '#00bcd4', 'icon': 'I', 'gradient_start': '#20cce4', 'gradient_end': '#00acc4', 'label': 'Interface', 'category': 'Code Structures'},
|
|
154
|
+
'template_class': {'color': '#7c4dff', 'icon': 'TC', 'gradient_start': '#8c5dff', 'gradient_end': '#6c3def', 'label': 'Template Class', 'category': 'Code Structures'},
|
|
155
|
+
'union': {'color': '#795548', 'icon': 'U', 'gradient_start': '#896558', 'gradient_end': '#694538', 'label': 'Union', 'category': 'Code Structures'},
|
|
156
|
+
|
|
157
|
+
# Functions
|
|
158
|
+
'function': {'color': '#50c878', 'icon': 'fn', 'gradient_start': '#60d888', 'gradient_end': '#40b868', 'label': 'Function', 'category': 'Functions'},
|
|
159
|
+
'method': {'color': '#66bb6a', 'icon': 'm', 'gradient_start': '#76cb7a', 'gradient_end': '#56ab5a', 'label': 'Method', 'category': 'Functions'},
|
|
160
|
+
'constructor': {'color': '#81c784', 'icon': 'ctor', 'gradient_start': '#91d794', 'gradient_end': '#71b774', 'label': 'Constructor', 'category': 'Functions'},
|
|
161
|
+
'destructor': {'color': '#a5d6a7', 'icon': 'dtor', 'gradient_start': '#b5e6b7', 'gradient_end': '#95c697', 'label': 'Destructor', 'category': 'Functions'},
|
|
162
|
+
'lambda': {'color': '#4db6ac', 'icon': 'λ', 'gradient_start': '#5dc6bc', 'gradient_end': '#3da69c', 'label': 'Lambda', 'category': 'Functions'},
|
|
163
|
+
'operator': {'color': '#26a69a', 'icon': 'op', 'gradient_start': '#36b6aa', 'gradient_end': '#16968a', 'label': 'Operator', 'category': 'Functions'},
|
|
164
|
+
|
|
165
|
+
# Data
|
|
166
|
+
'object': {'color': '#e040fb', 'icon': 'O', 'gradient_start': '#f050ff', 'gradient_end': '#c030db', 'label': 'Object', 'category': 'Data'},
|
|
167
|
+
'variable': {'color': '#ba68c8', 'icon': 'var', 'gradient_start': '#ca78d8', 'gradient_end': '#aa58b8', 'label': 'Variable', 'category': 'Data'},
|
|
168
|
+
'constant': {'color': '#ce93d8', 'icon': 'const', 'gradient_start': '#dea3e8', 'gradient_end': '#be83c8', 'label': 'Constant', 'category': 'Data'},
|
|
169
|
+
'definition': {'color': '#ff9800', 'icon': 'D', 'gradient_start': '#ffa820', 'gradient_end': '#e08800', 'label': 'Definition', 'category': 'Data'},
|
|
170
|
+
'typedef': {'color': '#ffb74d', 'icon': 'T', 'gradient_start': '#ffc75d', 'gradient_end': '#efa73d', 'label': 'Typedef', 'category': 'Data'},
|
|
171
|
+
'pointer': {'color': '#ff7043', 'icon': '*', 'gradient_start': '#ff8053', 'gradient_end': '#ef6033', 'label': 'Pointer', 'category': 'Data'},
|
|
172
|
+
|
|
173
|
+
# Organization
|
|
174
|
+
'namespace': {'color': '#607d8b', 'icon': 'N', 'gradient_start': '#708d9b', 'gradient_end': '#506d7b', 'label': 'Namespace', 'category': 'Organization'},
|
|
175
|
+
'module': {'color': '#78909c', 'icon': 'M', 'gradient_start': '#88a0ac', 'gradient_end': '#68808c', 'label': 'Module', 'category': 'Organization'},
|
|
176
|
+
'package': {'color': '#90a4ae', 'icon': 'P', 'gradient_start': '#a0b4be', 'gradient_end': '#80949e', 'label': 'Package', 'category': 'Organization'},
|
|
177
|
+
'file': {'color': '#b0bec5', 'icon': 'F', 'gradient_start': '#c0ced5', 'gradient_end': '#a0aeb5', 'label': 'File', 'category': 'Organization'},
|
|
178
|
+
'folder': {'color': '#8d6e63', 'icon': 'D', 'gradient_start': '#9d7e73', 'gradient_end': '#7d5e53', 'label': 'Folder', 'category': 'Organization'},
|
|
179
|
+
'header': {'color': '#a1887f', 'icon': 'H', 'gradient_start': '#b1988f', 'gradient_end': '#91786f', 'label': 'Header', 'category': 'Organization'},
|
|
180
|
+
|
|
181
|
+
# Flow
|
|
182
|
+
'condition': {'color': '#ef5350', 'icon': '?', 'gradient_start': '#ff6360', 'gradient_end': '#df4340', 'label': 'Condition', 'category': 'Flow'},
|
|
183
|
+
'loop': {'color': '#e57373', 'icon': '⟳', 'gradient_start': '#f58383', 'gradient_end': '#d56363', 'label': 'Loop', 'category': 'Flow'},
|
|
184
|
+
'exception': {'color': '#f44336', 'icon': '!', 'gradient_start': '#ff5346', 'gradient_end': '#e43326', 'label': 'Exception', 'category': 'Flow'},
|
|
185
|
+
'async': {'color': '#42a5f5', 'icon': '⚡', 'gradient_start': '#52b5ff', 'gradient_end': '#3295e5', 'label': 'Async', 'category': 'Flow'},
|
|
186
|
+
'callback': {'color': '#5c6bc0', 'icon': '↩', 'gradient_start': '#6c7bd0', 'gradient_end': '#4c5bb0', 'label': 'Callback', 'category': 'Flow'},
|
|
187
|
+
'event': {'color': '#7e57c2', 'icon': '⚑', 'gradient_start': '#8e67d2', 'gradient_end': '#6e47b2', 'label': 'Event', 'category': 'Flow'},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ============================================================================
|
|
192
|
+
# Data Structures
|
|
193
|
+
# ============================================================================
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class PortData:
|
|
197
|
+
"""Data for a connection port on a node."""
|
|
198
|
+
id: str = ""
|
|
199
|
+
port_type: str = "bidirectional" # input, output, bidirectional
|
|
200
|
+
data_type: str = "any" # any, int, float, string, object, function
|
|
201
|
+
name: str = ""
|
|
202
|
+
|
|
203
|
+
def __post_init__(self):
|
|
204
|
+
if not self.id:
|
|
205
|
+
self.id = str(uuid.uuid4())[:8]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class NodeData:
|
|
210
|
+
"""Data for a visual node in the CodeMaker"""
|
|
211
|
+
id: str = ""
|
|
212
|
+
node_type: str = "class"
|
|
213
|
+
name: str = ""
|
|
214
|
+
description: str = ""
|
|
215
|
+
x: float = 0.0
|
|
216
|
+
y: float = 0.0
|
|
217
|
+
width: float = 200.0
|
|
218
|
+
height: float = 100.0
|
|
219
|
+
color: str = ""
|
|
220
|
+
connections: List[str] = field(default_factory=list)
|
|
221
|
+
properties: Dict[str, Any] = field(default_factory=dict)
|
|
222
|
+
ports: List[Dict] = field(default_factory=list)
|
|
223
|
+
group_id: str = ""
|
|
224
|
+
generated_files: Dict[str, str] = field(default_factory=dict) # 'python': path, 'plugin': path
|
|
225
|
+
created_at: str = ""
|
|
226
|
+
updated_at: str = ""
|
|
227
|
+
|
|
228
|
+
def __post_init__(self):
|
|
229
|
+
if not self.id:
|
|
230
|
+
self.id = str(uuid.uuid4())[:8]
|
|
231
|
+
if not self.color:
|
|
232
|
+
self.color = NODE_TYPES.get(self.node_type, {}).get('color', '#4a9eff')
|
|
233
|
+
if not self.created_at:
|
|
234
|
+
self.created_at = datetime.now().isoformat()
|
|
235
|
+
self.updated_at = datetime.now().isoformat()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class ConnectionData:
|
|
240
|
+
"""Data for a connection between nodes"""
|
|
241
|
+
id: str = ""
|
|
242
|
+
start_node_id: str = ""
|
|
243
|
+
end_node_id: str = ""
|
|
244
|
+
start_port: str = ""
|
|
245
|
+
end_port: str = ""
|
|
246
|
+
label: str = ""
|
|
247
|
+
color: str = "#4a9eff"
|
|
248
|
+
style: str = "solid" # solid, dashed, dotted
|
|
249
|
+
|
|
250
|
+
def __post_init__(self):
|
|
251
|
+
if not self.id:
|
|
252
|
+
self.id = str(uuid.uuid4())[:8]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class GroupData:
|
|
257
|
+
"""Data for a node group"""
|
|
258
|
+
id: str = ""
|
|
259
|
+
name: str = ""
|
|
260
|
+
color: str = "#607d8b"
|
|
261
|
+
node_ids: List[str] = field(default_factory=list)
|
|
262
|
+
collapsed: bool = False
|
|
263
|
+
x: float = 0.0
|
|
264
|
+
y: float = 0.0
|
|
265
|
+
width: float = 300.0
|
|
266
|
+
height: float = 200.0
|
|
267
|
+
|
|
268
|
+
def __post_init__(self):
|
|
269
|
+
if not self.id:
|
|
270
|
+
self.id = str(uuid.uuid4())[:8]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class TemplateData:
|
|
275
|
+
"""Data for a node template"""
|
|
276
|
+
name: str = ""
|
|
277
|
+
category: str = "Custom"
|
|
278
|
+
description: str = ""
|
|
279
|
+
nodes: List[Dict] = field(default_factory=list)
|
|
280
|
+
connections: List[Dict] = field(default_factory=list)
|
|
281
|
+
code_template: str = ""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass
|
|
285
|
+
class MapData:
|
|
286
|
+
"""Data for a complete mindmap file"""
|
|
287
|
+
name: str = ""
|
|
288
|
+
description: str = ""
|
|
289
|
+
nodes: List[NodeData] = field(default_factory=list)
|
|
290
|
+
connections: List[ConnectionData] = field(default_factory=list)
|
|
291
|
+
groups: List[GroupData] = field(default_factory=list)
|
|
292
|
+
viewport_x: float = 0.0
|
|
293
|
+
viewport_y: float = 0.0
|
|
294
|
+
viewport_zoom: float = 1.0
|
|
295
|
+
grid_visible: bool = True
|
|
296
|
+
snap_to_grid: bool = False
|
|
297
|
+
created_at: str = ""
|
|
298
|
+
updated_at: str = ""
|
|
299
|
+
|
|
300
|
+
def __post_init__(self):
|
|
301
|
+
if not self.created_at:
|
|
302
|
+
self.created_at = datetime.now().isoformat()
|
|
303
|
+
self.updated_at = datetime.now().isoformat()
|
|
304
|
+
|
|
305
|
+
def to_dict(self) -> dict:
|
|
306
|
+
return {
|
|
307
|
+
'name': self.name,
|
|
308
|
+
'description': self.description,
|
|
309
|
+
'nodes': [asdict(n) for n in self.nodes],
|
|
310
|
+
'connections': [asdict(c) for c in self.connections],
|
|
311
|
+
'groups': [asdict(g) for g in self.groups],
|
|
312
|
+
'viewport_x': self.viewport_x,
|
|
313
|
+
'viewport_y': self.viewport_y,
|
|
314
|
+
'viewport_zoom': self.viewport_zoom,
|
|
315
|
+
'grid_visible': self.grid_visible,
|
|
316
|
+
'snap_to_grid': self.snap_to_grid,
|
|
317
|
+
'created_at': self.created_at,
|
|
318
|
+
'updated_at': datetime.now().isoformat()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
def from_dict(cls, data: dict) -> 'MapData':
|
|
323
|
+
nodes = [NodeData(**n) for n in data.get('nodes', [])]
|
|
324
|
+
connections = [ConnectionData(**c) for c in data.get('connections', [])]
|
|
325
|
+
groups = [GroupData(**g) for g in data.get('groups', [])]
|
|
326
|
+
return cls(
|
|
327
|
+
name=data.get('name', 'Untitled'),
|
|
328
|
+
description=data.get('description', ''),
|
|
329
|
+
nodes=nodes,
|
|
330
|
+
connections=connections,
|
|
331
|
+
groups=groups,
|
|
332
|
+
viewport_x=data.get('viewport_x', 0.0),
|
|
333
|
+
viewport_y=data.get('viewport_y', 0.0),
|
|
334
|
+
viewport_zoom=data.get('viewport_zoom', 1.0),
|
|
335
|
+
grid_visible=data.get('grid_visible', True),
|
|
336
|
+
snap_to_grid=data.get('snap_to_grid', False),
|
|
337
|
+
created_at=data.get('created_at', ''),
|
|
338
|
+
updated_at=data.get('updated_at', '')
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
if PYQT_AVAILABLE:
|
|
343
|
+
|
|
344
|
+
# ========================================================================
|
|
345
|
+
# Undo/Redo Commands
|
|
346
|
+
# ========================================================================
|
|
347
|
+
|
|
348
|
+
class AddNodeCommand(QUndoCommand):
|
|
349
|
+
"""Undoable command for adding a node."""
|
|
350
|
+
def __init__(self, canvas, node_data: NodeData, description="Add Node"):
|
|
351
|
+
super().__init__(description)
|
|
352
|
+
self.canvas = canvas
|
|
353
|
+
self.node_data = node_data
|
|
354
|
+
self.node_id = node_data.id
|
|
355
|
+
|
|
356
|
+
def redo(self):
|
|
357
|
+
self.canvas._add_node_internal(self.node_data)
|
|
358
|
+
|
|
359
|
+
def undo(self):
|
|
360
|
+
if self.node_id in self.canvas.nodes:
|
|
361
|
+
self.canvas._delete_node_internal(self.canvas.nodes[self.node_id])
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class DeleteNodeCommand(QUndoCommand):
|
|
365
|
+
"""Undoable command for deleting a node."""
|
|
366
|
+
def __init__(self, canvas, node, description="Delete Node"):
|
|
367
|
+
super().__init__(description)
|
|
368
|
+
self.canvas = canvas
|
|
369
|
+
self.node_data = copy.deepcopy(node.node_data)
|
|
370
|
+
self.connections_data = []
|
|
371
|
+
for conn in node.connections:
|
|
372
|
+
self.connections_data.append(copy.deepcopy(conn.connection_data))
|
|
373
|
+
|
|
374
|
+
def redo(self):
|
|
375
|
+
if self.node_data.id in self.canvas.nodes:
|
|
376
|
+
self.canvas._delete_node_internal(self.canvas.nodes[self.node_data.id])
|
|
377
|
+
|
|
378
|
+
def undo(self):
|
|
379
|
+
self.canvas._add_node_internal(self.node_data)
|
|
380
|
+
for conn_data in self.connections_data:
|
|
381
|
+
if conn_data.start_node_id in self.canvas.nodes and conn_data.end_node_id in self.canvas.nodes:
|
|
382
|
+
self.canvas._add_connection_internal(conn_data)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class MoveNodeCommand(QUndoCommand):
|
|
386
|
+
"""Undoable command for moving nodes."""
|
|
387
|
+
def __init__(self, canvas, node_id: str, old_pos: QPointF, new_pos: QPointF, description="Move Node"):
|
|
388
|
+
super().__init__(description)
|
|
389
|
+
self.canvas = canvas
|
|
390
|
+
self.node_id = node_id
|
|
391
|
+
self.old_pos = old_pos
|
|
392
|
+
self.new_pos = new_pos
|
|
393
|
+
|
|
394
|
+
def redo(self):
|
|
395
|
+
if self.node_id in self.canvas.nodes:
|
|
396
|
+
node = self.canvas.nodes[self.node_id]
|
|
397
|
+
node.setPos(self.new_pos)
|
|
398
|
+
node.node_data.x = self.new_pos.x()
|
|
399
|
+
node.node_data.y = self.new_pos.y()
|
|
400
|
+
for conn in node.connections:
|
|
401
|
+
conn.update_path()
|
|
402
|
+
|
|
403
|
+
def undo(self):
|
|
404
|
+
if self.node_id in self.canvas.nodes:
|
|
405
|
+
node = self.canvas.nodes[self.node_id]
|
|
406
|
+
node.setPos(self.old_pos)
|
|
407
|
+
node.node_data.x = self.old_pos.x()
|
|
408
|
+
node.node_data.y = self.old_pos.y()
|
|
409
|
+
for conn in node.connections:
|
|
410
|
+
conn.update_path()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class AddConnectionCommand(QUndoCommand):
|
|
414
|
+
"""Undoable command for adding a connection."""
|
|
415
|
+
def __init__(self, canvas, conn_data: ConnectionData, description="Add Connection"):
|
|
416
|
+
super().__init__(description)
|
|
417
|
+
self.canvas = canvas
|
|
418
|
+
self.conn_data = conn_data
|
|
419
|
+
|
|
420
|
+
def redo(self):
|
|
421
|
+
self.canvas._add_connection_internal(self.conn_data)
|
|
422
|
+
|
|
423
|
+
def undo(self):
|
|
424
|
+
for conn in self.canvas.connections:
|
|
425
|
+
if conn.connection_data.id == self.conn_data.id:
|
|
426
|
+
self.canvas._delete_connection_internal(conn)
|
|
427
|
+
break
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ========================================================================
|
|
431
|
+
# Animated Button with Sophisticated Hover Effects
|
|
432
|
+
# ========================================================================
|
|
433
|
+
|
|
434
|
+
class AnimatedButton(QPushButton):
|
|
435
|
+
"""Modern animated button with gradient hover effects"""
|
|
436
|
+
|
|
437
|
+
def __init__(self, text: str, icon_text: str = "", accent_color: str = None, parent=None):
|
|
438
|
+
super().__init__(parent)
|
|
439
|
+
self._text = text
|
|
440
|
+
self._icon_text = icon_text
|
|
441
|
+
self._accent = QColor(accent_color or THEME['accent_blue'])
|
|
442
|
+
self._hover_progress = 0.0
|
|
443
|
+
self._pressed = False
|
|
444
|
+
|
|
445
|
+
self.setText(f" {icon_text} {text}" if icon_text else f" {text}")
|
|
446
|
+
self.setMinimumHeight(44)
|
|
447
|
+
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
448
|
+
self.setFont(QFont(SYSTEM_FONT, 11))
|
|
449
|
+
|
|
450
|
+
self._animation = QVariantAnimation(self)
|
|
451
|
+
self._animation.setDuration(150)
|
|
452
|
+
self._animation.setEasingCurve(QEasingCurve.Type.OutCubic)
|
|
453
|
+
self._animation.valueChanged.connect(self._update_hover)
|
|
454
|
+
|
|
455
|
+
self._update_style()
|
|
456
|
+
|
|
457
|
+
def _update_hover(self, value):
|
|
458
|
+
self._hover_progress = value
|
|
459
|
+
self._update_style()
|
|
460
|
+
|
|
461
|
+
def _update_style(self):
|
|
462
|
+
progress = self._hover_progress
|
|
463
|
+
|
|
464
|
+
bg_base = QColor(THEME['bg_tertiary'])
|
|
465
|
+
bg_hover = self._accent.darker(140)
|
|
466
|
+
|
|
467
|
+
r = int(bg_base.red() + (bg_hover.red() - bg_base.red()) * progress)
|
|
468
|
+
g = int(bg_base.green() + (bg_hover.green() - bg_base.green()) * progress)
|
|
469
|
+
b = int(bg_base.blue() + (bg_hover.blue() - bg_base.blue()) * progress)
|
|
470
|
+
|
|
471
|
+
bg_color = f"rgb({r}, {g}, {b})"
|
|
472
|
+
border_color = self._accent.name() if progress > 0.3 else THEME['border']
|
|
473
|
+
|
|
474
|
+
if self._pressed:
|
|
475
|
+
bg_color = self._accent.name()
|
|
476
|
+
border_color = self._accent.lighter(120).name()
|
|
477
|
+
|
|
478
|
+
self.setStyleSheet(f'''
|
|
479
|
+
QPushButton {{
|
|
480
|
+
background-color: {bg_color};
|
|
481
|
+
border: 1px solid {border_color};
|
|
482
|
+
border-radius: 8px;
|
|
483
|
+
color: {THEME['text_primary']};
|
|
484
|
+
padding: 10px 16px;
|
|
485
|
+
text-align: left;
|
|
486
|
+
font-weight: 500;
|
|
487
|
+
}}
|
|
488
|
+
''')
|
|
489
|
+
|
|
490
|
+
def enterEvent(self, event):
|
|
491
|
+
self._animation.setStartValue(self._hover_progress)
|
|
492
|
+
self._animation.setEndValue(1.0)
|
|
493
|
+
self._animation.start()
|
|
494
|
+
super().enterEvent(event)
|
|
495
|
+
|
|
496
|
+
def leaveEvent(self, event):
|
|
497
|
+
self._animation.setStartValue(self._hover_progress)
|
|
498
|
+
self._animation.setEndValue(0.0)
|
|
499
|
+
self._animation.start()
|
|
500
|
+
super().leaveEvent(event)
|
|
501
|
+
|
|
502
|
+
def mousePressEvent(self, event):
|
|
503
|
+
self._pressed = True
|
|
504
|
+
self._update_style()
|
|
505
|
+
super().mousePressEvent(event)
|
|
506
|
+
|
|
507
|
+
def mouseReleaseEvent(self, event):
|
|
508
|
+
self._pressed = False
|
|
509
|
+
self._update_style()
|
|
510
|
+
super().mouseReleaseEvent(event)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# ========================================================================
|
|
514
|
+
# Visual Node with Professional Design
|
|
515
|
+
# ========================================================================
|
|
516
|
+
|
|
517
|
+
class VisualNode(QGraphicsRectItem):
|
|
518
|
+
"""A professional visual node with gradients, shadows, and animations"""
|
|
519
|
+
|
|
520
|
+
def __init__(self, node_data: NodeData, parent=None):
|
|
521
|
+
super().__init__(parent)
|
|
522
|
+
self.node_data = node_data
|
|
523
|
+
self.connections: List['ConnectionLine'] = []
|
|
524
|
+
self._hover = False
|
|
525
|
+
self._selected = False
|
|
526
|
+
self._drag_start_pos = None
|
|
527
|
+
|
|
528
|
+
self.setRect(0, 0, node_data.width, node_data.height)
|
|
529
|
+
self.setPos(node_data.x, node_data.y)
|
|
530
|
+
|
|
531
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
|
532
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
|
533
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
|
534
|
+
self.setAcceptHoverEvents(True)
|
|
535
|
+
self.setZValue(1)
|
|
536
|
+
|
|
537
|
+
self.node_theme = NODE_TYPES.get(node_data.node_type, NODE_TYPES['class'])
|
|
538
|
+
self.base_color = QColor(self.node_theme['color'])
|
|
539
|
+
|
|
540
|
+
self._create_visual_elements()
|
|
541
|
+
self._apply_shadow()
|
|
542
|
+
|
|
543
|
+
def _create_visual_elements(self):
|
|
544
|
+
"""Create all visual elements for the node"""
|
|
545
|
+
rect = self.rect()
|
|
546
|
+
w, h = rect.width(), rect.height()
|
|
547
|
+
|
|
548
|
+
gradient = QLinearGradient(0, 0, 0, h)
|
|
549
|
+
gradient.setColorAt(0, QColor(self.node_theme['gradient_start']))
|
|
550
|
+
gradient.setColorAt(0.3, QColor(self.node_theme['color']))
|
|
551
|
+
gradient.setColorAt(1, QColor(self.node_theme['gradient_end']))
|
|
552
|
+
|
|
553
|
+
self.setBrush(QBrush(gradient))
|
|
554
|
+
self.setPen(QPen(self.base_color.darker(120), 2))
|
|
555
|
+
|
|
556
|
+
self.header = QGraphicsRectItem(0, 0, w, 32, self)
|
|
557
|
+
header_gradient = QLinearGradient(0, 0, 0, 32)
|
|
558
|
+
header_gradient.setColorAt(0, QColor(255, 255, 255, 30))
|
|
559
|
+
header_gradient.setColorAt(1, QColor(0, 0, 0, 30))
|
|
560
|
+
self.header.setBrush(QBrush(header_gradient))
|
|
561
|
+
self.header.setPen(QPen(Qt.PenStyle.NoPen))
|
|
562
|
+
|
|
563
|
+
icon_size = 26
|
|
564
|
+
self.icon_bg = QGraphicsEllipseItem(8, 4, icon_size, icon_size, self)
|
|
565
|
+
self.icon_bg.setBrush(QBrush(QColor(THEME['bg_dark'])))
|
|
566
|
+
self.icon_bg.setPen(QPen(self.base_color.lighter(120), 1.5))
|
|
567
|
+
|
|
568
|
+
icon_text = self.node_theme['icon']
|
|
569
|
+
self.icon_label = QGraphicsTextItem(icon_text, self)
|
|
570
|
+
self.icon_label.setDefaultTextColor(self.base_color.lighter(130))
|
|
571
|
+
icon_font = QFont(MONO_FONT, 10, QFont.Weight.Bold)
|
|
572
|
+
self.icon_label.setFont(icon_font)
|
|
573
|
+
icon_rect = self.icon_label.boundingRect()
|
|
574
|
+
self.icon_label.setPos(
|
|
575
|
+
8 + (icon_size - icon_rect.width()) / 2,
|
|
576
|
+
4 + (icon_size - icon_rect.height()) / 2
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
self.name_label = QGraphicsTextItem(self.node_data.name, self)
|
|
580
|
+
self.name_label.setDefaultTextColor(QColor("#ffffff"))
|
|
581
|
+
self.name_label.setFont(QFont(SYSTEM_FONT, 11, QFont.Weight.Bold))
|
|
582
|
+
self.name_label.setPos(40, 6)
|
|
583
|
+
|
|
584
|
+
type_text = self.node_theme['label'].upper()
|
|
585
|
+
self.type_label = QGraphicsTextItem(type_text, self)
|
|
586
|
+
self.type_label.setDefaultTextColor(QColor(255, 255, 255, 150))
|
|
587
|
+
self.type_label.setFont(QFont(SYSTEM_FONT, 8))
|
|
588
|
+
type_rect = self.type_label.boundingRect()
|
|
589
|
+
self.type_label.setPos(w - type_rect.width() - 10, 10)
|
|
590
|
+
|
|
591
|
+
# Description area with background (always created, hidden if empty)
|
|
592
|
+
self.desc_bg = QGraphicsRectItem(5, 36, w - 10, h - 44, self)
|
|
593
|
+
self.desc_bg.setBrush(QBrush(QColor(0, 0, 0, 40)))
|
|
594
|
+
self.desc_bg.setPen(QPen(Qt.PenStyle.NoPen))
|
|
595
|
+
|
|
596
|
+
self.desc_label = QGraphicsTextItem("", self)
|
|
597
|
+
self.desc_label.setDefaultTextColor(QColor(255, 255, 255, 200))
|
|
598
|
+
self.desc_label.setFont(QFont(SYSTEM_FONT, 9))
|
|
599
|
+
self.desc_label.setTextWidth(w - 20)
|
|
600
|
+
self.desc_label.setPos(10, 38)
|
|
601
|
+
|
|
602
|
+
if self.node_data.description:
|
|
603
|
+
desc_text = self.node_data.description[:120]
|
|
604
|
+
if len(self.node_data.description) > 120:
|
|
605
|
+
desc_text += "..."
|
|
606
|
+
self.desc_label.setPlainText(desc_text)
|
|
607
|
+
self.desc_bg.setVisible(True)
|
|
608
|
+
else:
|
|
609
|
+
self.desc_bg.setVisible(False)
|
|
610
|
+
|
|
611
|
+
# Connection points - 8 for source nodes, 2 for others
|
|
612
|
+
point_size = 10
|
|
613
|
+
self.connection_points = []
|
|
614
|
+
|
|
615
|
+
port_count = self.node_theme.get('port_count', 2)
|
|
616
|
+
|
|
617
|
+
if port_count == 8:
|
|
618
|
+
# Source node: 4 ports on each side
|
|
619
|
+
for i in range(4):
|
|
620
|
+
y_pos = 20 + (h - 40) * (i + 0.5) / 4
|
|
621
|
+
|
|
622
|
+
# Right side ports
|
|
623
|
+
right_point = QGraphicsEllipseItem(
|
|
624
|
+
w - point_size/2, y_pos - point_size/2,
|
|
625
|
+
point_size, point_size, self
|
|
626
|
+
)
|
|
627
|
+
right_point.setBrush(QBrush(QColor(THEME['bg_dark'])))
|
|
628
|
+
right_point.setPen(QPen(self.base_color, 2))
|
|
629
|
+
right_point.setZValue(2)
|
|
630
|
+
self.connection_points.append((f'right_{i}', right_point))
|
|
631
|
+
|
|
632
|
+
# Left side ports
|
|
633
|
+
left_point = QGraphicsEllipseItem(
|
|
634
|
+
-point_size/2, y_pos - point_size/2,
|
|
635
|
+
point_size, point_size, self
|
|
636
|
+
)
|
|
637
|
+
left_point.setBrush(QBrush(QColor(THEME['bg_dark'])))
|
|
638
|
+
left_point.setPen(QPen(self.base_color, 2))
|
|
639
|
+
left_point.setZValue(2)
|
|
640
|
+
self.connection_points.append((f'left_{i}', left_point))
|
|
641
|
+
else:
|
|
642
|
+
# Standard node: 1 port on each side
|
|
643
|
+
right_point = QGraphicsEllipseItem(
|
|
644
|
+
w - point_size/2, h/2 - point_size/2,
|
|
645
|
+
point_size, point_size, self
|
|
646
|
+
)
|
|
647
|
+
right_point.setBrush(QBrush(QColor(THEME['bg_dark'])))
|
|
648
|
+
right_point.setPen(QPen(self.base_color, 2))
|
|
649
|
+
right_point.setZValue(2)
|
|
650
|
+
self.connection_points.append(('right', right_point))
|
|
651
|
+
|
|
652
|
+
left_point = QGraphicsEllipseItem(
|
|
653
|
+
-point_size/2, h/2 - point_size/2,
|
|
654
|
+
point_size, point_size, self
|
|
655
|
+
)
|
|
656
|
+
left_point.setBrush(QBrush(QColor(THEME['bg_dark'])))
|
|
657
|
+
left_point.setPen(QPen(self.base_color, 2))
|
|
658
|
+
left_point.setZValue(2)
|
|
659
|
+
self.connection_points.append(('left', left_point))
|
|
660
|
+
|
|
661
|
+
self.bottom_line = QGraphicsRectItem(10, h - 4, w - 20, 2, self)
|
|
662
|
+
self.bottom_line.setBrush(QBrush(self.base_color.lighter(130)))
|
|
663
|
+
self.bottom_line.setPen(QPen(Qt.PenStyle.NoPen))
|
|
664
|
+
|
|
665
|
+
def _apply_shadow(self):
|
|
666
|
+
"""Apply drop shadow effect"""
|
|
667
|
+
shadow = QGraphicsDropShadowEffect()
|
|
668
|
+
shadow.setBlurRadius(25)
|
|
669
|
+
shadow.setColor(QColor(0, 0, 0, 120))
|
|
670
|
+
shadow.setOffset(4, 4)
|
|
671
|
+
self.setGraphicsEffect(shadow)
|
|
672
|
+
|
|
673
|
+
def itemChange(self, change, value):
|
|
674
|
+
"""Handle position changes"""
|
|
675
|
+
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
|
676
|
+
pos = self.pos()
|
|
677
|
+
self.node_data.x = pos.x()
|
|
678
|
+
self.node_data.y = pos.y()
|
|
679
|
+
self.node_data.updated_at = datetime.now().isoformat()
|
|
680
|
+
for conn in self.connections:
|
|
681
|
+
conn.update_path()
|
|
682
|
+
elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
|
|
683
|
+
self._selected = value
|
|
684
|
+
self._update_visual_state()
|
|
685
|
+
return super().itemChange(change, value)
|
|
686
|
+
|
|
687
|
+
def _update_visual_state(self):
|
|
688
|
+
"""Update visual appearance based on state"""
|
|
689
|
+
if self._selected:
|
|
690
|
+
pen_width = 3
|
|
691
|
+
pen_color = self.base_color.lighter(150)
|
|
692
|
+
elif self._hover:
|
|
693
|
+
pen_width = 2.5
|
|
694
|
+
pen_color = self.base_color.lighter(130)
|
|
695
|
+
else:
|
|
696
|
+
pen_width = 2
|
|
697
|
+
pen_color = self.base_color.darker(120)
|
|
698
|
+
|
|
699
|
+
self.setPen(QPen(pen_color, pen_width))
|
|
700
|
+
|
|
701
|
+
def get_connection_point(self, side: str) -> QPointF:
|
|
702
|
+
"""Get the connection point position for a side"""
|
|
703
|
+
rect = self.sceneBoundingRect()
|
|
704
|
+
if side == 'right':
|
|
705
|
+
return QPointF(rect.right(), rect.center().y())
|
|
706
|
+
elif side == 'left':
|
|
707
|
+
return QPointF(rect.left(), rect.center().y())
|
|
708
|
+
elif side == 'top':
|
|
709
|
+
return QPointF(rect.center().x(), rect.top())
|
|
710
|
+
elif side == 'bottom':
|
|
711
|
+
return QPointF(rect.center().x(), rect.bottom())
|
|
712
|
+
return rect.center()
|
|
713
|
+
|
|
714
|
+
def get_nearest_connection_point(self, target: QPointF) -> Tuple[str, QPointF]:
|
|
715
|
+
"""Get the nearest connection point to a target"""
|
|
716
|
+
sides = ['left', 'right', 'top', 'bottom']
|
|
717
|
+
min_dist = float('inf')
|
|
718
|
+
nearest = ('right', self.get_connection_point('right'))
|
|
719
|
+
|
|
720
|
+
for side in sides:
|
|
721
|
+
point = self.get_connection_point(side)
|
|
722
|
+
dist = (point.x() - target.x())**2 + (point.y() - target.y())**2
|
|
723
|
+
if dist < min_dist:
|
|
724
|
+
min_dist = dist
|
|
725
|
+
nearest = (side, point)
|
|
726
|
+
|
|
727
|
+
return nearest
|
|
728
|
+
|
|
729
|
+
def hoverEnterEvent(self, event):
|
|
730
|
+
self._hover = True
|
|
731
|
+
self._update_visual_state()
|
|
732
|
+
super().hoverEnterEvent(event)
|
|
733
|
+
|
|
734
|
+
def hoverLeaveEvent(self, event):
|
|
735
|
+
self._hover = False
|
|
736
|
+
self._update_visual_state()
|
|
737
|
+
super().hoverLeaveEvent(event)
|
|
738
|
+
|
|
739
|
+
def mousePressEvent(self, event):
|
|
740
|
+
self._drag_start_pos = self.pos()
|
|
741
|
+
super().mousePressEvent(event)
|
|
742
|
+
|
|
743
|
+
def mouseReleaseEvent(self, event):
|
|
744
|
+
if self._drag_start_pos and self._drag_start_pos != self.pos():
|
|
745
|
+
canvas = self.scene().views()[0] if self.scene() and self.scene().views() else None
|
|
746
|
+
if canvas and hasattr(canvas, 'undo_stack'):
|
|
747
|
+
cmd = MoveNodeCommand(canvas, self.node_data.id, self._drag_start_pos, self.pos())
|
|
748
|
+
canvas.undo_stack.push(cmd)
|
|
749
|
+
self._drag_start_pos = None
|
|
750
|
+
super().mouseReleaseEvent(event)
|
|
751
|
+
|
|
752
|
+
def update_name(self, name: str):
|
|
753
|
+
"""Update the displayed name"""
|
|
754
|
+
self.node_data.name = name
|
|
755
|
+
self.name_label.setPlainText(name)
|
|
756
|
+
self.node_data.updated_at = datetime.now().isoformat()
|
|
757
|
+
|
|
758
|
+
def update_description(self, desc: str):
|
|
759
|
+
"""Update the description"""
|
|
760
|
+
self.node_data.description = desc
|
|
761
|
+
if hasattr(self, 'desc_label'):
|
|
762
|
+
display_text = desc[:120] + "..." if len(desc) > 120 else desc
|
|
763
|
+
self.desc_label.setPlainText(display_text)
|
|
764
|
+
if hasattr(self, 'desc_bg'):
|
|
765
|
+
self.desc_bg.setVisible(bool(desc))
|
|
766
|
+
self.node_data.updated_at = datetime.now().isoformat()
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ========================================================================
|
|
770
|
+
# Connection Line with Bezier Curves and Arrows
|
|
771
|
+
# ========================================================================
|
|
772
|
+
|
|
773
|
+
class ConnectionLine(QGraphicsPathItem):
|
|
774
|
+
"""A sophisticated connection line with bezier curves and arrow head"""
|
|
775
|
+
|
|
776
|
+
def __init__(self, start_node: VisualNode, end_node: VisualNode,
|
|
777
|
+
connection_data: ConnectionData = None, parent=None):
|
|
778
|
+
super().__init__(parent)
|
|
779
|
+
self.start_node = start_node
|
|
780
|
+
self.end_node = end_node
|
|
781
|
+
|
|
782
|
+
if connection_data:
|
|
783
|
+
self.connection_data = connection_data
|
|
784
|
+
else:
|
|
785
|
+
self.connection_data = ConnectionData(
|
|
786
|
+
id=str(uuid.uuid4())[:8],
|
|
787
|
+
start_node_id=start_node.node_data.id,
|
|
788
|
+
end_node_id=end_node.node_data.id
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
start_node.connections.append(self)
|
|
792
|
+
end_node.connections.append(self)
|
|
793
|
+
|
|
794
|
+
self.line_color = QColor(self.connection_data.color)
|
|
795
|
+
self._hover = False
|
|
796
|
+
self._setup_style()
|
|
797
|
+
self.setZValue(0)
|
|
798
|
+
self.setAcceptHoverEvents(True)
|
|
799
|
+
|
|
800
|
+
self.arrow_head = QGraphicsPolygonItem(self)
|
|
801
|
+
self.arrow_head.setBrush(QBrush(self.line_color))
|
|
802
|
+
self.arrow_head.setPen(QPen(Qt.PenStyle.NoPen))
|
|
803
|
+
|
|
804
|
+
self.label_item = None
|
|
805
|
+
if self.connection_data.label:
|
|
806
|
+
self._create_label()
|
|
807
|
+
|
|
808
|
+
self.update_path()
|
|
809
|
+
|
|
810
|
+
def _setup_style(self):
|
|
811
|
+
"""Setup pen style based on connection data"""
|
|
812
|
+
pen = QPen(self.line_color, 2.5)
|
|
813
|
+
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
814
|
+
|
|
815
|
+
if self.connection_data.style == 'dashed':
|
|
816
|
+
pen.setStyle(Qt.PenStyle.DashLine)
|
|
817
|
+
elif self.connection_data.style == 'dotted':
|
|
818
|
+
pen.setStyle(Qt.PenStyle.DotLine)
|
|
819
|
+
else:
|
|
820
|
+
pen.setStyle(Qt.PenStyle.SolidLine)
|
|
821
|
+
|
|
822
|
+
self.setPen(pen)
|
|
823
|
+
|
|
824
|
+
def _create_label(self):
|
|
825
|
+
"""Create label for the connection"""
|
|
826
|
+
self.label_item = QGraphicsTextItem(self.connection_data.label, self)
|
|
827
|
+
self.label_item.setDefaultTextColor(QColor(THEME['text_secondary']))
|
|
828
|
+
self.label_item.setFont(QFont(SYSTEM_FONT, 9))
|
|
829
|
+
|
|
830
|
+
def update_path(self):
|
|
831
|
+
"""Update the bezier curve path"""
|
|
832
|
+
end_center = self.end_node.sceneBoundingRect().center()
|
|
833
|
+
start_side, start_point = self.start_node.get_nearest_connection_point(end_center)
|
|
834
|
+
|
|
835
|
+
start_center = self.start_node.sceneBoundingRect().center()
|
|
836
|
+
end_side, end_point = self.end_node.get_nearest_connection_point(start_center)
|
|
837
|
+
|
|
838
|
+
dx = end_point.x() - start_point.x()
|
|
839
|
+
dy = end_point.y() - start_point.y()
|
|
840
|
+
dist = math.sqrt(dx*dx + dy*dy)
|
|
841
|
+
tension = min(dist * 0.4, 150)
|
|
842
|
+
|
|
843
|
+
def get_direction(side):
|
|
844
|
+
if side == 'right':
|
|
845
|
+
return QPointF(1, 0)
|
|
846
|
+
elif side == 'left':
|
|
847
|
+
return QPointF(-1, 0)
|
|
848
|
+
elif side == 'top':
|
|
849
|
+
return QPointF(0, -1)
|
|
850
|
+
else:
|
|
851
|
+
return QPointF(0, 1)
|
|
852
|
+
|
|
853
|
+
start_dir = get_direction(start_side)
|
|
854
|
+
end_dir = get_direction(end_side)
|
|
855
|
+
|
|
856
|
+
ctrl1 = QPointF(
|
|
857
|
+
start_point.x() + start_dir.x() * tension,
|
|
858
|
+
start_point.y() + start_dir.y() * tension
|
|
859
|
+
)
|
|
860
|
+
ctrl2 = QPointF(
|
|
861
|
+
end_point.x() + end_dir.x() * tension,
|
|
862
|
+
end_point.y() + end_dir.y() * tension
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
path = QPainterPath()
|
|
866
|
+
path.moveTo(start_point)
|
|
867
|
+
path.cubicTo(ctrl1, ctrl2, end_point)
|
|
868
|
+
self.setPath(path)
|
|
869
|
+
|
|
870
|
+
self._update_arrow(end_point, ctrl2)
|
|
871
|
+
|
|
872
|
+
if self.label_item:
|
|
873
|
+
mid_point = path.pointAtPercent(0.5)
|
|
874
|
+
label_rect = self.label_item.boundingRect()
|
|
875
|
+
self.label_item.setPos(
|
|
876
|
+
mid_point.x() - label_rect.width() / 2,
|
|
877
|
+
mid_point.y() - label_rect.height() / 2
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
def _update_arrow(self, tip: QPointF, control: QPointF):
|
|
881
|
+
"""Update arrow head at the end of the line"""
|
|
882
|
+
dx = tip.x() - control.x()
|
|
883
|
+
dy = tip.y() - control.y()
|
|
884
|
+
length = math.sqrt(dx*dx + dy*dy)
|
|
885
|
+
|
|
886
|
+
if length > 0:
|
|
887
|
+
dx /= length
|
|
888
|
+
dy /= length
|
|
889
|
+
|
|
890
|
+
arrow_size = 12
|
|
891
|
+
angle = math.pi / 6
|
|
892
|
+
|
|
893
|
+
p1 = tip
|
|
894
|
+
p2 = QPointF(
|
|
895
|
+
tip.x() - arrow_size * (dx * math.cos(angle) - dy * math.sin(angle)),
|
|
896
|
+
tip.y() - arrow_size * (dy * math.cos(angle) + dx * math.sin(angle))
|
|
897
|
+
)
|
|
898
|
+
p3 = QPointF(
|
|
899
|
+
tip.x() - arrow_size * (dx * math.cos(angle) + dy * math.sin(angle)),
|
|
900
|
+
tip.y() - arrow_size * (dy * math.cos(angle) - dx * math.sin(angle))
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
self.arrow_head.setPolygon(QPolygonF([p1, p2, p3]))
|
|
904
|
+
|
|
905
|
+
def hoverEnterEvent(self, event):
|
|
906
|
+
self._hover = True
|
|
907
|
+
pen = self.pen()
|
|
908
|
+
pen.setWidth(4)
|
|
909
|
+
pen.setColor(self.line_color.lighter(130))
|
|
910
|
+
self.setPen(pen)
|
|
911
|
+
self.arrow_head.setBrush(QBrush(self.line_color.lighter(130)))
|
|
912
|
+
super().hoverEnterEvent(event)
|
|
913
|
+
|
|
914
|
+
def hoverLeaveEvent(self, event):
|
|
915
|
+
self._hover = False
|
|
916
|
+
self._setup_style()
|
|
917
|
+
self.arrow_head.setBrush(QBrush(self.line_color))
|
|
918
|
+
super().hoverLeaveEvent(event)
|
|
919
|
+
|
|
920
|
+
def remove(self):
|
|
921
|
+
"""Remove this connection"""
|
|
922
|
+
if self in self.start_node.connections:
|
|
923
|
+
self.start_node.connections.remove(self)
|
|
924
|
+
if self in self.end_node.connections:
|
|
925
|
+
self.end_node.connections.remove(self)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
# ========================================================================
|
|
929
|
+
# Node Properties Dialog
|
|
930
|
+
# ========================================================================
|
|
931
|
+
|
|
932
|
+
class NodePropertiesDialog(QDialog):
|
|
933
|
+
"""Dialog for editing node properties"""
|
|
934
|
+
|
|
935
|
+
def __init__(self, node: VisualNode, parent=None):
|
|
936
|
+
super().__init__(parent)
|
|
937
|
+
self.node = node
|
|
938
|
+
self.setWindowTitle("Node Properties")
|
|
939
|
+
self.setMinimumSize(450, 400)
|
|
940
|
+
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
|
|
941
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
942
|
+
|
|
943
|
+
self._setup_ui()
|
|
944
|
+
|
|
945
|
+
def _setup_ui(self):
|
|
946
|
+
container = QFrame(self)
|
|
947
|
+
container.setStyleSheet(f'''
|
|
948
|
+
QFrame {{
|
|
949
|
+
background-color: {THEME['bg_secondary']};
|
|
950
|
+
border: 1px solid {THEME['border']};
|
|
951
|
+
border-radius: 12px;
|
|
952
|
+
}}
|
|
953
|
+
QLabel {{
|
|
954
|
+
color: {THEME['text_primary']};
|
|
955
|
+
font-size: 12px;
|
|
956
|
+
}}
|
|
957
|
+
QLineEdit, QTextEdit, QComboBox {{
|
|
958
|
+
background-color: {THEME['bg_tertiary']};
|
|
959
|
+
border: 1px solid {THEME['border']};
|
|
960
|
+
border-radius: 6px;
|
|
961
|
+
padding: 8px;
|
|
962
|
+
color: {THEME['text_primary']};
|
|
963
|
+
font-size: 12px;
|
|
964
|
+
}}
|
|
965
|
+
QLineEdit:focus, QTextEdit:focus {{
|
|
966
|
+
border: 1px solid {THEME['accent_blue']};
|
|
967
|
+
}}
|
|
968
|
+
''')
|
|
969
|
+
|
|
970
|
+
main_layout = QVBoxLayout(self)
|
|
971
|
+
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
972
|
+
main_layout.addWidget(container)
|
|
973
|
+
|
|
974
|
+
layout = QVBoxLayout(container)
|
|
975
|
+
layout.setContentsMargins(20, 20, 20, 20)
|
|
976
|
+
layout.setSpacing(16)
|
|
977
|
+
|
|
978
|
+
header = QLabel("Edit Node")
|
|
979
|
+
header.setFont(QFont(SYSTEM_FONT, 14, QFont.Weight.Bold))
|
|
980
|
+
header.setStyleSheet(f'color: {THEME["accent_blue"]};')
|
|
981
|
+
layout.addWidget(header)
|
|
982
|
+
|
|
983
|
+
form = QFormLayout()
|
|
984
|
+
form.setSpacing(12)
|
|
985
|
+
|
|
986
|
+
self.name_edit = QLineEdit(self.node.node_data.name)
|
|
987
|
+
form.addRow("Name:", self.name_edit)
|
|
988
|
+
|
|
989
|
+
self.type_combo = QComboBox()
|
|
990
|
+
for ntype in NODE_TYPES.keys():
|
|
991
|
+
self.type_combo.addItem(NODE_TYPES[ntype]['label'], ntype)
|
|
992
|
+
idx = self.type_combo.findData(self.node.node_data.node_type)
|
|
993
|
+
if idx >= 0:
|
|
994
|
+
self.type_combo.setCurrentIndex(idx)
|
|
995
|
+
form.addRow("Type:", self.type_combo)
|
|
996
|
+
|
|
997
|
+
self.desc_edit = QTextEdit()
|
|
998
|
+
self.desc_edit.setPlainText(self.node.node_data.description)
|
|
999
|
+
self.desc_edit.setMaximumHeight(100)
|
|
1000
|
+
form.addRow("Description:", self.desc_edit)
|
|
1001
|
+
|
|
1002
|
+
layout.addLayout(form)
|
|
1003
|
+
|
|
1004
|
+
btn_layout = QHBoxLayout()
|
|
1005
|
+
btn_layout.addStretch()
|
|
1006
|
+
|
|
1007
|
+
cancel_btn = QPushButton("Cancel")
|
|
1008
|
+
cancel_btn.setStyleSheet(f'''
|
|
1009
|
+
QPushButton {{
|
|
1010
|
+
background-color: {THEME['bg_tertiary']};
|
|
1011
|
+
border: 1px solid {THEME['border']};
|
|
1012
|
+
border-radius: 6px;
|
|
1013
|
+
padding: 10px 24px;
|
|
1014
|
+
color: {THEME['text_primary']};
|
|
1015
|
+
}}
|
|
1016
|
+
QPushButton:hover {{
|
|
1017
|
+
background-color: {THEME['bg_hover']};
|
|
1018
|
+
}}
|
|
1019
|
+
''')
|
|
1020
|
+
cancel_btn.clicked.connect(self.reject)
|
|
1021
|
+
btn_layout.addWidget(cancel_btn)
|
|
1022
|
+
|
|
1023
|
+
save_btn = QPushButton("Save")
|
|
1024
|
+
save_btn.setStyleSheet(f'''
|
|
1025
|
+
QPushButton {{
|
|
1026
|
+
background-color: {THEME['accent_blue']};
|
|
1027
|
+
border: none;
|
|
1028
|
+
border-radius: 6px;
|
|
1029
|
+
padding: 10px 24px;
|
|
1030
|
+
color: white;
|
|
1031
|
+
font-weight: bold;
|
|
1032
|
+
}}
|
|
1033
|
+
QPushButton:hover {{
|
|
1034
|
+
background-color: {QColor(THEME['accent_blue']).lighter(110).name()};
|
|
1035
|
+
}}
|
|
1036
|
+
''')
|
|
1037
|
+
save_btn.clicked.connect(self.accept)
|
|
1038
|
+
btn_layout.addWidget(save_btn)
|
|
1039
|
+
|
|
1040
|
+
layout.addLayout(btn_layout)
|
|
1041
|
+
|
|
1042
|
+
def get_values(self) -> Tuple[str, str, str]:
|
|
1043
|
+
return (
|
|
1044
|
+
self.name_edit.text(),
|
|
1045
|
+
self.desc_edit.toPlainText(),
|
|
1046
|
+
self.type_combo.currentData()
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
# ========================================================================
|
|
1051
|
+
# Code Generator
|
|
1052
|
+
# ========================================================================
|
|
1053
|
+
|
|
1054
|
+
class CodeGenerator:
|
|
1055
|
+
"""Generates C++ and Python code from nodes."""
|
|
1056
|
+
|
|
1057
|
+
@staticmethod
|
|
1058
|
+
def generate_cpp_header(node: NodeData) -> str:
|
|
1059
|
+
"""Generate C++ header code for a node."""
|
|
1060
|
+
lines = []
|
|
1061
|
+
ntype = node.node_type
|
|
1062
|
+
name = node.name.replace(' ', '_')
|
|
1063
|
+
|
|
1064
|
+
if ntype == 'class':
|
|
1065
|
+
lines.append(f"class {name} {{")
|
|
1066
|
+
lines.append("public:")
|
|
1067
|
+
lines.append(f" {name}();")
|
|
1068
|
+
lines.append(f" ~{name}();")
|
|
1069
|
+
lines.append("")
|
|
1070
|
+
lines.append("private:")
|
|
1071
|
+
lines.append("};")
|
|
1072
|
+
elif ntype == 'struct':
|
|
1073
|
+
lines.append(f"struct {name} {{")
|
|
1074
|
+
lines.append("};")
|
|
1075
|
+
elif ntype == 'enum':
|
|
1076
|
+
lines.append(f"enum class {name} {{")
|
|
1077
|
+
lines.append(" Value1,")
|
|
1078
|
+
lines.append(" Value2,")
|
|
1079
|
+
lines.append("};")
|
|
1080
|
+
elif ntype == 'function':
|
|
1081
|
+
lines.append(f"void {name}();")
|
|
1082
|
+
elif ntype == 'interface':
|
|
1083
|
+
lines.append(f"class I{name} {{")
|
|
1084
|
+
lines.append("public:")
|
|
1085
|
+
lines.append(f" virtual ~I{name}() = default;")
|
|
1086
|
+
lines.append("};")
|
|
1087
|
+
elif ntype == 'namespace':
|
|
1088
|
+
lines.append(f"namespace {name} {{")
|
|
1089
|
+
lines.append("")
|
|
1090
|
+
lines.append(f"}} // namespace {name}")
|
|
1091
|
+
else:
|
|
1092
|
+
lines.append(f"// {node.node_type}: {name}")
|
|
1093
|
+
|
|
1094
|
+
return '\n'.join(lines)
|
|
1095
|
+
|
|
1096
|
+
@staticmethod
|
|
1097
|
+
def generate_cpp_source(node: NodeData) -> str:
|
|
1098
|
+
"""Generate C++ source code for a node."""
|
|
1099
|
+
lines = []
|
|
1100
|
+
ntype = node.node_type
|
|
1101
|
+
name = node.name.replace(' ', '_')
|
|
1102
|
+
|
|
1103
|
+
if ntype == 'class':
|
|
1104
|
+
lines.append(f"#include \"{name}.h\"")
|
|
1105
|
+
lines.append("")
|
|
1106
|
+
lines.append(f"{name}::{name}() {{")
|
|
1107
|
+
lines.append("}")
|
|
1108
|
+
lines.append("")
|
|
1109
|
+
lines.append(f"{name}::~{name}() {{")
|
|
1110
|
+
lines.append("}")
|
|
1111
|
+
elif ntype == 'function':
|
|
1112
|
+
lines.append(f"void {name}() {{")
|
|
1113
|
+
lines.append(" // TODO: Implement")
|
|
1114
|
+
lines.append("}")
|
|
1115
|
+
else:
|
|
1116
|
+
lines.append(f"// Implementation for {name}")
|
|
1117
|
+
|
|
1118
|
+
return '\n'.join(lines)
|
|
1119
|
+
|
|
1120
|
+
@staticmethod
|
|
1121
|
+
def generate_python(node: NodeData) -> str:
|
|
1122
|
+
"""Generate Python code for a node."""
|
|
1123
|
+
lines = []
|
|
1124
|
+
ntype = node.node_type
|
|
1125
|
+
name = node.name.replace(' ', '_')
|
|
1126
|
+
|
|
1127
|
+
if ntype in ('class', 'struct'):
|
|
1128
|
+
lines.append(f"class {name}:")
|
|
1129
|
+
lines.append(' """')
|
|
1130
|
+
if node.description:
|
|
1131
|
+
lines.append(f" {node.description}")
|
|
1132
|
+
lines.append(' """')
|
|
1133
|
+
lines.append("")
|
|
1134
|
+
lines.append(" def __init__(self):")
|
|
1135
|
+
lines.append(" pass")
|
|
1136
|
+
elif ntype == 'function':
|
|
1137
|
+
lines.append(f"def {name}():")
|
|
1138
|
+
lines.append(' """')
|
|
1139
|
+
if node.description:
|
|
1140
|
+
lines.append(f" {node.description}")
|
|
1141
|
+
lines.append(' """')
|
|
1142
|
+
lines.append(" pass")
|
|
1143
|
+
elif ntype == 'enum':
|
|
1144
|
+
lines.append("from enum import Enum, auto")
|
|
1145
|
+
lines.append("")
|
|
1146
|
+
lines.append(f"class {name}(Enum):")
|
|
1147
|
+
lines.append(" VALUE1 = auto()")
|
|
1148
|
+
lines.append(" VALUE2 = auto()")
|
|
1149
|
+
elif ntype == 'interface':
|
|
1150
|
+
lines.append("from abc import ABC, abstractmethod")
|
|
1151
|
+
lines.append("")
|
|
1152
|
+
lines.append(f"class {name}(ABC):")
|
|
1153
|
+
lines.append(' """Abstract base class."""')
|
|
1154
|
+
lines.append("")
|
|
1155
|
+
lines.append(" @abstractmethod")
|
|
1156
|
+
lines.append(" def method(self):")
|
|
1157
|
+
lines.append(" pass")
|
|
1158
|
+
else:
|
|
1159
|
+
lines.append(f"# {node.node_type}: {name}")
|
|
1160
|
+
|
|
1161
|
+
return '\n'.join(lines)
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
# ========================================================================
|
|
1165
|
+
# Code Preview Dialog
|
|
1166
|
+
# ========================================================================
|
|
1167
|
+
|
|
1168
|
+
class CodePreviewDialog(QDialog):
|
|
1169
|
+
"""Dialog for previewing generated code."""
|
|
1170
|
+
|
|
1171
|
+
def __init__(self, node: VisualNode, parent=None):
|
|
1172
|
+
super().__init__(parent)
|
|
1173
|
+
self.node = node
|
|
1174
|
+
self.setWindowTitle("Code Preview")
|
|
1175
|
+
self.setMinimumSize(600, 500)
|
|
1176
|
+
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
|
|
1177
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
1178
|
+
self._setup_ui()
|
|
1179
|
+
|
|
1180
|
+
def _setup_ui(self):
|
|
1181
|
+
container = QFrame(self)
|
|
1182
|
+
container.setStyleSheet(f'''
|
|
1183
|
+
QFrame {{
|
|
1184
|
+
background-color: {THEME['bg_secondary']};
|
|
1185
|
+
border: 1px solid {THEME['border']};
|
|
1186
|
+
border-radius: 12px;
|
|
1187
|
+
}}
|
|
1188
|
+
''')
|
|
1189
|
+
|
|
1190
|
+
main_layout = QVBoxLayout(self)
|
|
1191
|
+
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
1192
|
+
main_layout.addWidget(container)
|
|
1193
|
+
|
|
1194
|
+
layout = QVBoxLayout(container)
|
|
1195
|
+
layout.setContentsMargins(20, 20, 20, 20)
|
|
1196
|
+
|
|
1197
|
+
header = QHBoxLayout()
|
|
1198
|
+
title = QLabel("Code Preview")
|
|
1199
|
+
title.setFont(QFont(SYSTEM_FONT, 14, QFont.Weight.Bold))
|
|
1200
|
+
title.setStyleSheet(f'color: {THEME["accent_blue"]};')
|
|
1201
|
+
header.addWidget(title)
|
|
1202
|
+
header.addStretch()
|
|
1203
|
+
|
|
1204
|
+
close_btn = QPushButton("×")
|
|
1205
|
+
close_btn.setFixedSize(28, 28)
|
|
1206
|
+
close_btn.setStyleSheet(f'''
|
|
1207
|
+
QPushButton {{
|
|
1208
|
+
background: transparent;
|
|
1209
|
+
color: {THEME['text_secondary']};
|
|
1210
|
+
font-size: 18px;
|
|
1211
|
+
border: none;
|
|
1212
|
+
}}
|
|
1213
|
+
QPushButton:hover {{
|
|
1214
|
+
color: {THEME['accent_red']};
|
|
1215
|
+
}}
|
|
1216
|
+
''')
|
|
1217
|
+
close_btn.clicked.connect(self.close)
|
|
1218
|
+
header.addWidget(close_btn)
|
|
1219
|
+
layout.addLayout(header)
|
|
1220
|
+
|
|
1221
|
+
tabs = QTabWidget()
|
|
1222
|
+
tabs.setStyleSheet(f'''
|
|
1223
|
+
QTabWidget::pane {{
|
|
1224
|
+
border: 1px solid {THEME['border']};
|
|
1225
|
+
border-radius: 6px;
|
|
1226
|
+
background: {THEME['bg_tertiary']};
|
|
1227
|
+
}}
|
|
1228
|
+
QTabBar::tab {{
|
|
1229
|
+
background: {THEME['bg_tertiary']};
|
|
1230
|
+
color: {THEME['text_secondary']};
|
|
1231
|
+
padding: 10px 20px;
|
|
1232
|
+
border-top-left-radius: 6px;
|
|
1233
|
+
border-top-right-radius: 6px;
|
|
1234
|
+
}}
|
|
1235
|
+
QTabBar::tab:selected {{
|
|
1236
|
+
background: {THEME['accent_blue']};
|
|
1237
|
+
color: white;
|
|
1238
|
+
}}
|
|
1239
|
+
''')
|
|
1240
|
+
|
|
1241
|
+
cpp_header = QPlainTextEdit()
|
|
1242
|
+
cpp_header.setPlainText(CodeGenerator.generate_cpp_header(self.node.node_data))
|
|
1243
|
+
cpp_header.setFont(QFont(MONO_FONT, 11))
|
|
1244
|
+
cpp_header.setStyleSheet(f'''
|
|
1245
|
+
QPlainTextEdit {{
|
|
1246
|
+
background: {THEME['bg_dark']};
|
|
1247
|
+
color: {THEME['text_primary']};
|
|
1248
|
+
border: none;
|
|
1249
|
+
padding: 10px;
|
|
1250
|
+
}}
|
|
1251
|
+
''')
|
|
1252
|
+
tabs.addTab(cpp_header, "C++ Header")
|
|
1253
|
+
|
|
1254
|
+
cpp_source = QPlainTextEdit()
|
|
1255
|
+
cpp_source.setPlainText(CodeGenerator.generate_cpp_source(self.node.node_data))
|
|
1256
|
+
cpp_source.setFont(QFont(MONO_FONT, 11))
|
|
1257
|
+
cpp_source.setStyleSheet(cpp_header.styleSheet())
|
|
1258
|
+
tabs.addTab(cpp_source, "C++ Source")
|
|
1259
|
+
|
|
1260
|
+
python_code = QPlainTextEdit()
|
|
1261
|
+
python_code.setPlainText(CodeGenerator.generate_python(self.node.node_data))
|
|
1262
|
+
python_code.setFont(QFont(MONO_FONT, 11))
|
|
1263
|
+
python_code.setStyleSheet(cpp_header.styleSheet())
|
|
1264
|
+
tabs.addTab(python_code, "Python")
|
|
1265
|
+
|
|
1266
|
+
layout.addWidget(tabs)
|
|
1267
|
+
|
|
1268
|
+
btn_layout = QHBoxLayout()
|
|
1269
|
+
btn_layout.addStretch()
|
|
1270
|
+
|
|
1271
|
+
copy_btn = QPushButton("Copy to Clipboard")
|
|
1272
|
+
copy_btn.setStyleSheet(f'''
|
|
1273
|
+
QPushButton {{
|
|
1274
|
+
background-color: {THEME['accent_blue']};
|
|
1275
|
+
border: none;
|
|
1276
|
+
border-radius: 6px;
|
|
1277
|
+
padding: 10px 24px;
|
|
1278
|
+
color: white;
|
|
1279
|
+
font-weight: bold;
|
|
1280
|
+
}}
|
|
1281
|
+
''')
|
|
1282
|
+
copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(
|
|
1283
|
+
tabs.currentWidget().toPlainText()
|
|
1284
|
+
))
|
|
1285
|
+
btn_layout.addWidget(copy_btn)
|
|
1286
|
+
layout.addLayout(btn_layout)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
# ========================================================================
|
|
1290
|
+
# Search Panel
|
|
1291
|
+
# ========================================================================
|
|
1292
|
+
|
|
1293
|
+
class SearchPanel(QFrame):
|
|
1294
|
+
"""Panel for searching and filtering nodes."""
|
|
1295
|
+
|
|
1296
|
+
search_requested = pyqtSignal(str)
|
|
1297
|
+
filter_changed = pyqtSignal(str)
|
|
1298
|
+
|
|
1299
|
+
def __init__(self, parent=None):
|
|
1300
|
+
super().__init__(parent)
|
|
1301
|
+
self.setFixedHeight(50)
|
|
1302
|
+
self._setup_ui()
|
|
1303
|
+
|
|
1304
|
+
def _setup_ui(self):
|
|
1305
|
+
self.setStyleSheet(f'''
|
|
1306
|
+
QFrame {{
|
|
1307
|
+
background: {THEME['bg_secondary']};
|
|
1308
|
+
border-bottom: 1px solid {THEME['border']};
|
|
1309
|
+
}}
|
|
1310
|
+
''')
|
|
1311
|
+
|
|
1312
|
+
layout = QHBoxLayout(self)
|
|
1313
|
+
layout.setContentsMargins(16, 8, 16, 8)
|
|
1314
|
+
|
|
1315
|
+
self.search_input = QLineEdit()
|
|
1316
|
+
self.search_input.setPlaceholderText("Search nodes... (Ctrl+F)")
|
|
1317
|
+
self.search_input.setStyleSheet(f'''
|
|
1318
|
+
QLineEdit {{
|
|
1319
|
+
background: {THEME['bg_tertiary']};
|
|
1320
|
+
border: 1px solid {THEME['border']};
|
|
1321
|
+
border-radius: 6px;
|
|
1322
|
+
padding: 8px 12px;
|
|
1323
|
+
color: {THEME['text_primary']};
|
|
1324
|
+
}}
|
|
1325
|
+
QLineEdit:focus {{
|
|
1326
|
+
border: 1px solid {THEME['accent_blue']};
|
|
1327
|
+
}}
|
|
1328
|
+
''')
|
|
1329
|
+
self.search_input.textChanged.connect(self.search_requested.emit)
|
|
1330
|
+
layout.addWidget(self.search_input)
|
|
1331
|
+
|
|
1332
|
+
self.filter_combo = QComboBox()
|
|
1333
|
+
self.filter_combo.addItem("All Types", "all")
|
|
1334
|
+
for category, types in NODE_CATEGORIES.items():
|
|
1335
|
+
self.filter_combo.addItem(f"─ {category} ─", f"cat:{category}")
|
|
1336
|
+
for ntype in types:
|
|
1337
|
+
if ntype in NODE_TYPES:
|
|
1338
|
+
self.filter_combo.addItem(f" {NODE_TYPES[ntype]['label']}", ntype)
|
|
1339
|
+
self.filter_combo.setStyleSheet(f'''
|
|
1340
|
+
QComboBox {{
|
|
1341
|
+
background: {THEME['bg_tertiary']};
|
|
1342
|
+
border: 1px solid {THEME['border']};
|
|
1343
|
+
border-radius: 6px;
|
|
1344
|
+
padding: 8px 12px;
|
|
1345
|
+
color: {THEME['text_primary']};
|
|
1346
|
+
min-width: 150px;
|
|
1347
|
+
}}
|
|
1348
|
+
''')
|
|
1349
|
+
self.filter_combo.currentIndexChanged.connect(
|
|
1350
|
+
lambda: self.filter_changed.emit(self.filter_combo.currentData())
|
|
1351
|
+
)
|
|
1352
|
+
layout.addWidget(self.filter_combo)
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
# ========================================================================
|
|
1356
|
+
# Minimap Widget
|
|
1357
|
+
# ========================================================================
|
|
1358
|
+
|
|
1359
|
+
class MinimapWidget(QFrame):
|
|
1360
|
+
"""Minimap overview of the canvas."""
|
|
1361
|
+
|
|
1362
|
+
def __init__(self, canvas: 'CodeMakerCanvas', parent=None):
|
|
1363
|
+
super().__init__(parent)
|
|
1364
|
+
self.canvas = canvas
|
|
1365
|
+
self.setFixedSize(200, 150)
|
|
1366
|
+
self._setup_ui()
|
|
1367
|
+
|
|
1368
|
+
def _setup_ui(self):
|
|
1369
|
+
self.setStyleSheet(f'''
|
|
1370
|
+
QFrame {{
|
|
1371
|
+
background: {THEME['bg_secondary']};
|
|
1372
|
+
border: 1px solid {THEME['border']};
|
|
1373
|
+
border-radius: 8px;
|
|
1374
|
+
}}
|
|
1375
|
+
''')
|
|
1376
|
+
|
|
1377
|
+
def paintEvent(self, event):
|
|
1378
|
+
super().paintEvent(event)
|
|
1379
|
+
painter = QPainter(self)
|
|
1380
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
1381
|
+
|
|
1382
|
+
painter.fillRect(self.rect(), QColor(THEME['bg_dark']))
|
|
1383
|
+
|
|
1384
|
+
if not self.canvas.nodes:
|
|
1385
|
+
return
|
|
1386
|
+
|
|
1387
|
+
min_x = min(n.node_data.x for n in self.canvas.nodes.values())
|
|
1388
|
+
max_x = max(n.node_data.x + n.node_data.width for n in self.canvas.nodes.values())
|
|
1389
|
+
min_y = min(n.node_data.y for n in self.canvas.nodes.values())
|
|
1390
|
+
max_y = max(n.node_data.y + n.node_data.height for n in self.canvas.nodes.values())
|
|
1391
|
+
|
|
1392
|
+
content_width = max_x - min_x + 100
|
|
1393
|
+
content_height = max_y - min_y + 100
|
|
1394
|
+
|
|
1395
|
+
scale_x = (self.width() - 20) / max(content_width, 1)
|
|
1396
|
+
scale_y = (self.height() - 20) / max(content_height, 1)
|
|
1397
|
+
scale = min(scale_x, scale_y)
|
|
1398
|
+
|
|
1399
|
+
offset_x = 10 - min_x * scale
|
|
1400
|
+
offset_y = 10 - min_y * scale
|
|
1401
|
+
|
|
1402
|
+
for node in self.canvas.nodes.values():
|
|
1403
|
+
x = node.node_data.x * scale + offset_x
|
|
1404
|
+
y = node.node_data.y * scale + offset_y
|
|
1405
|
+
w = node.node_data.width * scale
|
|
1406
|
+
h = node.node_data.height * scale
|
|
1407
|
+
|
|
1408
|
+
color = QColor(node.node_theme['color'])
|
|
1409
|
+
painter.fillRect(int(x), int(y), int(w), int(h), color)
|
|
1410
|
+
|
|
1411
|
+
viewport_rect = self.canvas.mapToScene(self.canvas.viewport().rect()).boundingRect()
|
|
1412
|
+
vx = viewport_rect.x() * scale + offset_x
|
|
1413
|
+
vy = viewport_rect.y() * scale + offset_y
|
|
1414
|
+
vw = viewport_rect.width() * scale
|
|
1415
|
+
vh = viewport_rect.height() * scale
|
|
1416
|
+
|
|
1417
|
+
painter.setPen(QPen(QColor(THEME['accent_blue']), 2))
|
|
1418
|
+
painter.drawRect(int(vx), int(vy), int(vw), int(vh))
|
|
1419
|
+
|
|
1420
|
+
def update_minimap(self):
|
|
1421
|
+
self.update()
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
# ========================================================================
|
|
1425
|
+
# CodeMaker Canvas
|
|
1426
|
+
# ========================================================================
|
|
1427
|
+
|
|
1428
|
+
class CodeMakerCanvas(QGraphicsView):
|
|
1429
|
+
"""Professional interactive canvas with smooth pan/zoom"""
|
|
1430
|
+
|
|
1431
|
+
node_count_changed = pyqtSignal(int)
|
|
1432
|
+
connection_count_changed = pyqtSignal(int)
|
|
1433
|
+
status_message = pyqtSignal(str)
|
|
1434
|
+
selection_changed = pyqtSignal(list)
|
|
1435
|
+
|
|
1436
|
+
def __init__(self, parent=None):
|
|
1437
|
+
super().__init__(parent)
|
|
1438
|
+
|
|
1439
|
+
self.scene = QGraphicsScene(self)
|
|
1440
|
+
self.scene.setSceneRect(-10000, -10000, 20000, 20000)
|
|
1441
|
+
self.setScene(self.scene)
|
|
1442
|
+
|
|
1443
|
+
self.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
1444
|
+
self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
|
1445
|
+
self.setRenderHint(QPainter.RenderHint.TextAntialiasing)
|
|
1446
|
+
self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate)
|
|
1447
|
+
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
1448
|
+
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
1449
|
+
self.setDragMode(QGraphicsView.DragMode.NoDrag)
|
|
1450
|
+
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
1451
|
+
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
1452
|
+
|
|
1453
|
+
self._panning = False
|
|
1454
|
+
self._pan_start = QPointF()
|
|
1455
|
+
self._zoom_level = 1.0
|
|
1456
|
+
self._connecting_mode = False
|
|
1457
|
+
self._connect_start_node: Optional[VisualNode] = None
|
|
1458
|
+
self._temp_line: Optional[QGraphicsLineItem] = None
|
|
1459
|
+
self._rubber_band: Optional[QRubberBand] = None
|
|
1460
|
+
self._rubber_band_origin = QPointF()
|
|
1461
|
+
self._grid_visible = True
|
|
1462
|
+
self._clipboard: List[NodeData] = []
|
|
1463
|
+
|
|
1464
|
+
self.nodes: Dict[str, VisualNode] = {}
|
|
1465
|
+
self.connections: List[ConnectionLine] = []
|
|
1466
|
+
self.groups: Dict[str, GroupData] = {}
|
|
1467
|
+
|
|
1468
|
+
self.undo_stack = QUndoStack(self)
|
|
1469
|
+
|
|
1470
|
+
self._draw_background()
|
|
1471
|
+
self._apply_style()
|
|
1472
|
+
self._setup_shortcuts()
|
|
1473
|
+
|
|
1474
|
+
def _apply_style(self):
|
|
1475
|
+
self.setStyleSheet(f'''
|
|
1476
|
+
QGraphicsView {{
|
|
1477
|
+
border: none;
|
|
1478
|
+
background-color: {THEME['bg_dark']};
|
|
1479
|
+
}}
|
|
1480
|
+
''')
|
|
1481
|
+
|
|
1482
|
+
def _setup_shortcuts(self):
|
|
1483
|
+
"""Setup keyboard shortcuts."""
|
|
1484
|
+
QShortcut(QKeySequence.StandardKey.Copy, self, self.copy_selected)
|
|
1485
|
+
QShortcut(QKeySequence.StandardKey.Paste, self, self.paste_nodes)
|
|
1486
|
+
QShortcut(QKeySequence.StandardKey.Cut, self, self.cut_selected)
|
|
1487
|
+
QShortcut(QKeySequence("Ctrl+D"), self, self.duplicate_selected)
|
|
1488
|
+
QShortcut(QKeySequence.StandardKey.Undo, self, self.undo_stack.undo)
|
|
1489
|
+
QShortcut(QKeySequence.StandardKey.Redo, self, self.undo_stack.redo)
|
|
1490
|
+
QShortcut(QKeySequence.StandardKey.SelectAll, self, self.select_all)
|
|
1491
|
+
QShortcut(QKeySequence("Ctrl+G"), self, self.group_selected)
|
|
1492
|
+
|
|
1493
|
+
def _draw_background(self):
|
|
1494
|
+
"""Draw professional grid background"""
|
|
1495
|
+
minor_pen = QPen(QColor(THEME['bg_secondary']), 0.5)
|
|
1496
|
+
minor_size = 25
|
|
1497
|
+
for x in range(-10000, 10000, minor_size):
|
|
1498
|
+
line = self.scene.addLine(x, -10000, x, 10000, minor_pen)
|
|
1499
|
+
line.setZValue(-100)
|
|
1500
|
+
for y in range(-10000, 10000, minor_size):
|
|
1501
|
+
line = self.scene.addLine(-10000, y, 10000, y, minor_pen)
|
|
1502
|
+
line.setZValue(-100)
|
|
1503
|
+
|
|
1504
|
+
major_pen = QPen(QColor(THEME['bg_tertiary']), 1)
|
|
1505
|
+
major_size = 100
|
|
1506
|
+
for x in range(-10000, 10000, major_size):
|
|
1507
|
+
line = self.scene.addLine(x, -10000, x, 10000, major_pen)
|
|
1508
|
+
line.setZValue(-99)
|
|
1509
|
+
for y in range(-10000, 10000, major_size):
|
|
1510
|
+
line = self.scene.addLine(-10000, y, 10000, y, major_pen)
|
|
1511
|
+
line.setZValue(-99)
|
|
1512
|
+
|
|
1513
|
+
center_pen = QPen(QColor(THEME['accent_blue']).darker(200), 2)
|
|
1514
|
+
self.scene.addLine(-50, 0, 50, 0, center_pen).setZValue(-98)
|
|
1515
|
+
self.scene.addLine(0, -50, 0, 50, center_pen).setZValue(-98)
|
|
1516
|
+
|
|
1517
|
+
def add_node(self, node_type: str, name: str, x: float = None, y: float = None) -> VisualNode:
|
|
1518
|
+
"""Add a new node to the canvas (with undo support)"""
|
|
1519
|
+
if x is None:
|
|
1520
|
+
x = self.mapToScene(self.viewport().rect().center()).x() - 100
|
|
1521
|
+
if y is None:
|
|
1522
|
+
y = self.mapToScene(self.viewport().rect().center()).y() - 50
|
|
1523
|
+
|
|
1524
|
+
node_data = NodeData(
|
|
1525
|
+
id=str(uuid.uuid4())[:8],
|
|
1526
|
+
node_type=node_type,
|
|
1527
|
+
name=name,
|
|
1528
|
+
x=x,
|
|
1529
|
+
y=y
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
cmd = AddNodeCommand(self, node_data, f"Add {name}")
|
|
1533
|
+
self.undo_stack.push(cmd)
|
|
1534
|
+
|
|
1535
|
+
return self.nodes.get(node_data.id)
|
|
1536
|
+
|
|
1537
|
+
def _add_node_internal(self, node_data: NodeData) -> VisualNode:
|
|
1538
|
+
"""Internal method to add node without undo."""
|
|
1539
|
+
visual_node = VisualNode(node_data)
|
|
1540
|
+
self.scene.addItem(visual_node)
|
|
1541
|
+
self.nodes[node_data.id] = visual_node
|
|
1542
|
+
self.node_count_changed.emit(len(self.nodes))
|
|
1543
|
+
self.status_message.emit(f"Created {node_data.node_type}: {node_data.name}")
|
|
1544
|
+
return visual_node
|
|
1545
|
+
|
|
1546
|
+
def connect_nodes(self, start: VisualNode, end: VisualNode,
|
|
1547
|
+
label: str = "", style: str = "solid") -> Optional[ConnectionLine]:
|
|
1548
|
+
"""Create a connection between nodes"""
|
|
1549
|
+
if start == end:
|
|
1550
|
+
return None
|
|
1551
|
+
|
|
1552
|
+
for conn in self.connections:
|
|
1553
|
+
if (conn.start_node == start and conn.end_node == end) or \
|
|
1554
|
+
(conn.start_node == end and conn.end_node == start):
|
|
1555
|
+
self.status_message.emit("Connection already exists")
|
|
1556
|
+
return None
|
|
1557
|
+
|
|
1558
|
+
conn_data = ConnectionData(
|
|
1559
|
+
id=str(uuid.uuid4())[:8],
|
|
1560
|
+
start_node_id=start.node_data.id,
|
|
1561
|
+
end_node_id=end.node_data.id,
|
|
1562
|
+
label=label,
|
|
1563
|
+
style=style
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
cmd = AddConnectionCommand(self, conn_data)
|
|
1567
|
+
self.undo_stack.push(cmd)
|
|
1568
|
+
|
|
1569
|
+
return self.connections[-1] if self.connections else None
|
|
1570
|
+
|
|
1571
|
+
def _add_connection_internal(self, conn_data: ConnectionData) -> Optional[ConnectionLine]:
|
|
1572
|
+
"""Internal method to add connection without undo."""
|
|
1573
|
+
if conn_data.start_node_id not in self.nodes or conn_data.end_node_id not in self.nodes:
|
|
1574
|
+
return None
|
|
1575
|
+
|
|
1576
|
+
start = self.nodes[conn_data.start_node_id]
|
|
1577
|
+
end = self.nodes[conn_data.end_node_id]
|
|
1578
|
+
|
|
1579
|
+
connection = ConnectionLine(start, end, conn_data)
|
|
1580
|
+
self.scene.addItem(connection)
|
|
1581
|
+
self.connections.append(connection)
|
|
1582
|
+
|
|
1583
|
+
if end.node_data.id not in start.node_data.connections:
|
|
1584
|
+
start.node_data.connections.append(end.node_data.id)
|
|
1585
|
+
if start.node_data.id not in end.node_data.connections:
|
|
1586
|
+
end.node_data.connections.append(start.node_data.id)
|
|
1587
|
+
|
|
1588
|
+
self.connection_count_changed.emit(len(self.connections))
|
|
1589
|
+
return connection
|
|
1590
|
+
|
|
1591
|
+
def delete_node(self, node: VisualNode):
|
|
1592
|
+
"""Delete a node and its connections"""
|
|
1593
|
+
cmd = DeleteNodeCommand(self, node, f"Delete {node.node_data.name}")
|
|
1594
|
+
self.undo_stack.push(cmd)
|
|
1595
|
+
|
|
1596
|
+
def _delete_node_internal(self, node: VisualNode):
|
|
1597
|
+
"""Internal method to delete node without undo."""
|
|
1598
|
+
for conn in list(node.connections):
|
|
1599
|
+
self._delete_connection_internal(conn)
|
|
1600
|
+
|
|
1601
|
+
if node.node_data.id in self.nodes:
|
|
1602
|
+
del self.nodes[node.node_data.id]
|
|
1603
|
+
|
|
1604
|
+
self.scene.removeItem(node)
|
|
1605
|
+
self.node_count_changed.emit(len(self.nodes))
|
|
1606
|
+
self.status_message.emit(f"Deleted: {node.node_data.name}")
|
|
1607
|
+
|
|
1608
|
+
def delete_connection(self, conn: ConnectionLine):
|
|
1609
|
+
"""Delete a connection"""
|
|
1610
|
+
self._delete_connection_internal(conn)
|
|
1611
|
+
|
|
1612
|
+
def _delete_connection_internal(self, conn: ConnectionLine):
|
|
1613
|
+
"""Internal method to delete connection."""
|
|
1614
|
+
if conn in self.connections:
|
|
1615
|
+
self.connections.remove(conn)
|
|
1616
|
+
conn.remove()
|
|
1617
|
+
self.scene.removeItem(conn)
|
|
1618
|
+
self.connection_count_changed.emit(len(self.connections))
|
|
1619
|
+
|
|
1620
|
+
def copy_selected(self):
|
|
1621
|
+
"""Copy selected nodes to clipboard."""
|
|
1622
|
+
selected = [item for item in self.scene.selectedItems() if isinstance(item, VisualNode)]
|
|
1623
|
+
if not selected:
|
|
1624
|
+
return
|
|
1625
|
+
|
|
1626
|
+
self._clipboard = [copy.deepcopy(node.node_data) for node in selected]
|
|
1627
|
+
self.status_message.emit(f"Copied {len(self._clipboard)} node(s)")
|
|
1628
|
+
|
|
1629
|
+
def paste_nodes(self):
|
|
1630
|
+
"""Paste nodes from clipboard."""
|
|
1631
|
+
if not self._clipboard:
|
|
1632
|
+
return
|
|
1633
|
+
|
|
1634
|
+
center = self.mapToScene(self.viewport().rect().center())
|
|
1635
|
+
offset = 50
|
|
1636
|
+
|
|
1637
|
+
self.scene.clearSelection()
|
|
1638
|
+
|
|
1639
|
+
for node_data in self._clipboard:
|
|
1640
|
+
new_data = copy.deepcopy(node_data)
|
|
1641
|
+
new_data.id = str(uuid.uuid4())[:8]
|
|
1642
|
+
new_data.x = center.x() + offset
|
|
1643
|
+
new_data.y = center.y() + offset
|
|
1644
|
+
new_data.connections = []
|
|
1645
|
+
|
|
1646
|
+
visual_node = self._add_node_internal(new_data)
|
|
1647
|
+
if visual_node:
|
|
1648
|
+
visual_node.setSelected(True)
|
|
1649
|
+
offset += 30
|
|
1650
|
+
|
|
1651
|
+
self.status_message.emit(f"Pasted {len(self._clipboard)} node(s)")
|
|
1652
|
+
|
|
1653
|
+
def cut_selected(self):
|
|
1654
|
+
"""Cut selected nodes."""
|
|
1655
|
+
self.copy_selected()
|
|
1656
|
+
for item in self.scene.selectedItems():
|
|
1657
|
+
if isinstance(item, VisualNode):
|
|
1658
|
+
self.delete_node(item)
|
|
1659
|
+
|
|
1660
|
+
def duplicate_selected(self):
|
|
1661
|
+
"""Duplicate selected nodes."""
|
|
1662
|
+
self.copy_selected()
|
|
1663
|
+
self.paste_nodes()
|
|
1664
|
+
|
|
1665
|
+
def select_all(self):
|
|
1666
|
+
"""Select all nodes."""
|
|
1667
|
+
for node in self.nodes.values():
|
|
1668
|
+
node.setSelected(True)
|
|
1669
|
+
|
|
1670
|
+
def group_selected(self):
|
|
1671
|
+
"""Group selected nodes."""
|
|
1672
|
+
selected = [item for item in self.scene.selectedItems() if isinstance(item, VisualNode)]
|
|
1673
|
+
if len(selected) < 2:
|
|
1674
|
+
self.status_message.emit("Select at least 2 nodes to group")
|
|
1675
|
+
return
|
|
1676
|
+
|
|
1677
|
+
name, ok = QInputDialog.getText(self, "Group Name", "Enter group name:")
|
|
1678
|
+
if not ok or not name:
|
|
1679
|
+
return
|
|
1680
|
+
|
|
1681
|
+
group_data = GroupData(
|
|
1682
|
+
name=name,
|
|
1683
|
+
node_ids=[node.node_data.id for node in selected]
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
for node in selected:
|
|
1687
|
+
node.node_data.group_id = group_data.id
|
|
1688
|
+
|
|
1689
|
+
self.groups[group_data.id] = group_data
|
|
1690
|
+
self.status_message.emit(f"Created group: {name}")
|
|
1691
|
+
|
|
1692
|
+
def search_nodes(self, query: str):
|
|
1693
|
+
"""Search and highlight nodes matching query."""
|
|
1694
|
+
query = query.lower()
|
|
1695
|
+
for node in self.nodes.values():
|
|
1696
|
+
matches = query in node.node_data.name.lower() or \
|
|
1697
|
+
query in node.node_data.description.lower()
|
|
1698
|
+
node.setOpacity(1.0 if matches or not query else 0.3)
|
|
1699
|
+
|
|
1700
|
+
def filter_by_type(self, type_filter: str):
|
|
1701
|
+
"""Filter nodes by type."""
|
|
1702
|
+
if type_filter == "all":
|
|
1703
|
+
for node in self.nodes.values():
|
|
1704
|
+
node.setVisible(True)
|
|
1705
|
+
elif type_filter.startswith("cat:"):
|
|
1706
|
+
category = type_filter[4:]
|
|
1707
|
+
types = NODE_CATEGORIES.get(category, [])
|
|
1708
|
+
for node in self.nodes.values():
|
|
1709
|
+
node.setVisible(node.node_data.node_type in types)
|
|
1710
|
+
else:
|
|
1711
|
+
for node in self.nodes.values():
|
|
1712
|
+
node.setVisible(node.node_data.node_type == type_filter)
|
|
1713
|
+
|
|
1714
|
+
def get_map_data(self) -> MapData:
|
|
1715
|
+
"""Export current state to MapData"""
|
|
1716
|
+
transform = self.transform()
|
|
1717
|
+
center = self.mapToScene(self.viewport().rect().center())
|
|
1718
|
+
|
|
1719
|
+
return MapData(
|
|
1720
|
+
name="Untitled",
|
|
1721
|
+
nodes=[n.node_data for n in self.nodes.values()],
|
|
1722
|
+
connections=[c.connection_data for c in self.connections],
|
|
1723
|
+
groups=list(self.groups.values()),
|
|
1724
|
+
viewport_x=center.x(),
|
|
1725
|
+
viewport_y=center.y(),
|
|
1726
|
+
viewport_zoom=self._zoom_level,
|
|
1727
|
+
grid_visible=self._grid_visible
|
|
1728
|
+
)
|
|
1729
|
+
|
|
1730
|
+
def load_map_data(self, map_data: MapData):
|
|
1731
|
+
"""Load map from MapData"""
|
|
1732
|
+
self.clear_all()
|
|
1733
|
+
|
|
1734
|
+
for node_data in map_data.nodes:
|
|
1735
|
+
visual_node = VisualNode(node_data)
|
|
1736
|
+
self.scene.addItem(visual_node)
|
|
1737
|
+
self.nodes[node_data.id] = visual_node
|
|
1738
|
+
|
|
1739
|
+
for conn_data in map_data.connections:
|
|
1740
|
+
if conn_data.start_node_id in self.nodes and conn_data.end_node_id in self.nodes:
|
|
1741
|
+
start = self.nodes[conn_data.start_node_id]
|
|
1742
|
+
end = self.nodes[conn_data.end_node_id]
|
|
1743
|
+
connection = ConnectionLine(start, end, conn_data)
|
|
1744
|
+
self.scene.addItem(connection)
|
|
1745
|
+
self.connections.append(connection)
|
|
1746
|
+
|
|
1747
|
+
for group_data in map_data.groups:
|
|
1748
|
+
self.groups[group_data.id] = group_data
|
|
1749
|
+
|
|
1750
|
+
self._zoom_level = map_data.viewport_zoom
|
|
1751
|
+
self.resetTransform()
|
|
1752
|
+
self.scale(self._zoom_level, self._zoom_level)
|
|
1753
|
+
self.centerOn(map_data.viewport_x, map_data.viewport_y)
|
|
1754
|
+
|
|
1755
|
+
self.node_count_changed.emit(len(self.nodes))
|
|
1756
|
+
self.connection_count_changed.emit(len(self.connections))
|
|
1757
|
+
|
|
1758
|
+
def clear_all(self):
|
|
1759
|
+
"""Clear canvas"""
|
|
1760
|
+
for conn in list(self.connections):
|
|
1761
|
+
conn.remove()
|
|
1762
|
+
self.scene.removeItem(conn)
|
|
1763
|
+
for node in list(self.nodes.values()):
|
|
1764
|
+
self.scene.removeItem(node)
|
|
1765
|
+
|
|
1766
|
+
self.nodes.clear()
|
|
1767
|
+
self.connections.clear()
|
|
1768
|
+
self.groups.clear()
|
|
1769
|
+
self.undo_stack.clear()
|
|
1770
|
+
|
|
1771
|
+
self.node_count_changed.emit(0)
|
|
1772
|
+
self.connection_count_changed.emit(0)
|
|
1773
|
+
|
|
1774
|
+
def export_to_image(self, file_path: str, format: str = "PNG"):
|
|
1775
|
+
"""Export canvas to image."""
|
|
1776
|
+
if not self.nodes:
|
|
1777
|
+
return
|
|
1778
|
+
|
|
1779
|
+
min_x = min(n.node_data.x for n in self.nodes.values()) - 50
|
|
1780
|
+
max_x = max(n.node_data.x + n.node_data.width for n in self.nodes.values()) + 50
|
|
1781
|
+
min_y = min(n.node_data.y for n in self.nodes.values()) - 50
|
|
1782
|
+
max_y = max(n.node_data.y + n.node_data.height for n in self.nodes.values()) + 50
|
|
1783
|
+
|
|
1784
|
+
rect = QRectF(min_x, min_y, max_x - min_x, max_y - min_y)
|
|
1785
|
+
|
|
1786
|
+
if format.upper() == "SVG":
|
|
1787
|
+
from PyQt6.QtSvg import QSvgGenerator
|
|
1788
|
+
generator = QSvgGenerator()
|
|
1789
|
+
generator.setFileName(file_path)
|
|
1790
|
+
generator.setSize(QSize(int(rect.width()), int(rect.height())))
|
|
1791
|
+
generator.setViewBox(QRect(0, 0, int(rect.width()), int(rect.height())))
|
|
1792
|
+
|
|
1793
|
+
painter = QPainter(generator)
|
|
1794
|
+
self.scene.render(painter, QRectF(0, 0, rect.width(), rect.height()), rect)
|
|
1795
|
+
painter.end()
|
|
1796
|
+
else:
|
|
1797
|
+
image = QImage(int(rect.width()), int(rect.height()), QImage.Format.Format_ARGB32)
|
|
1798
|
+
image.fill(QColor(THEME['bg_dark']))
|
|
1799
|
+
|
|
1800
|
+
painter = QPainter(image)
|
|
1801
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
1802
|
+
self.scene.render(painter, QRectF(0, 0, rect.width(), rect.height()), rect)
|
|
1803
|
+
painter.end()
|
|
1804
|
+
|
|
1805
|
+
image.save(file_path, format)
|
|
1806
|
+
|
|
1807
|
+
self.status_message.emit(f"Exported to {file_path}")
|
|
1808
|
+
|
|
1809
|
+
def start_connection_mode(self, start_node: VisualNode):
|
|
1810
|
+
"""Start connection mode from a node"""
|
|
1811
|
+
self._connecting_mode = True
|
|
1812
|
+
self._connect_start_node = start_node
|
|
1813
|
+
self.setCursor(Qt.CursorShape.CrossCursor)
|
|
1814
|
+
self.status_message.emit("Click on another node to connect (ESC to cancel)")
|
|
1815
|
+
|
|
1816
|
+
def cancel_connection_mode(self):
|
|
1817
|
+
"""Cancel connection mode"""
|
|
1818
|
+
self._connecting_mode = False
|
|
1819
|
+
self._connect_start_node = None
|
|
1820
|
+
if self._temp_line:
|
|
1821
|
+
self.scene.removeItem(self._temp_line)
|
|
1822
|
+
self._temp_line = None
|
|
1823
|
+
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
1824
|
+
self.status_message.emit("Connection cancelled")
|
|
1825
|
+
|
|
1826
|
+
def wheelEvent(self, event: QWheelEvent):
|
|
1827
|
+
"""Smooth zoom with wheel"""
|
|
1828
|
+
factor = 1.1
|
|
1829
|
+
|
|
1830
|
+
if event.angleDelta().y() > 0:
|
|
1831
|
+
if self._zoom_level < 3.0:
|
|
1832
|
+
self._zoom_level *= factor
|
|
1833
|
+
self.scale(factor, factor)
|
|
1834
|
+
else:
|
|
1835
|
+
if self._zoom_level > 0.2:
|
|
1836
|
+
self._zoom_level /= factor
|
|
1837
|
+
self.scale(1/factor, 1/factor)
|
|
1838
|
+
|
|
1839
|
+
def mousePressEvent(self, event: QMouseEvent):
|
|
1840
|
+
if event.button() == Qt.MouseButton.MiddleButton:
|
|
1841
|
+
self._panning = True
|
|
1842
|
+
self._pan_start = event.position()
|
|
1843
|
+
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
1844
|
+
elif self._connecting_mode and event.button() == Qt.MouseButton.LeftButton:
|
|
1845
|
+
scene_pos = self.mapToScene(event.pos())
|
|
1846
|
+
item = self.scene.itemAt(scene_pos, self.transform())
|
|
1847
|
+
|
|
1848
|
+
target_node = self._find_node_at(item)
|
|
1849
|
+
if target_node and target_node != self._connect_start_node:
|
|
1850
|
+
self.connect_nodes(self._connect_start_node, target_node)
|
|
1851
|
+
self.cancel_connection_mode()
|
|
1852
|
+
elif not target_node:
|
|
1853
|
+
self.cancel_connection_mode()
|
|
1854
|
+
elif event.button() == Qt.MouseButton.LeftButton:
|
|
1855
|
+
scene_pos = self.mapToScene(event.pos())
|
|
1856
|
+
item = self.scene.itemAt(scene_pos, self.transform())
|
|
1857
|
+
if not item or not self._find_node_at(item):
|
|
1858
|
+
self._rubber_band_origin = event.pos()
|
|
1859
|
+
if not self._rubber_band:
|
|
1860
|
+
self._rubber_band = QRubberBand(QRubberBand.Shape.Rectangle, self)
|
|
1861
|
+
self._rubber_band.setGeometry(QRect(event.pos(), QSize()))
|
|
1862
|
+
self._rubber_band.show()
|
|
1863
|
+
super().mousePressEvent(event)
|
|
1864
|
+
else:
|
|
1865
|
+
super().mousePressEvent(event)
|
|
1866
|
+
|
|
1867
|
+
def mouseMoveEvent(self, event: QMouseEvent):
|
|
1868
|
+
if self._panning:
|
|
1869
|
+
delta = event.position() - self._pan_start
|
|
1870
|
+
self._pan_start = event.position()
|
|
1871
|
+
self.horizontalScrollBar().setValue(
|
|
1872
|
+
int(self.horizontalScrollBar().value() - delta.x())
|
|
1873
|
+
)
|
|
1874
|
+
self.verticalScrollBar().setValue(
|
|
1875
|
+
int(self.verticalScrollBar().value() - delta.y())
|
|
1876
|
+
)
|
|
1877
|
+
elif self._rubber_band and self._rubber_band.isVisible():
|
|
1878
|
+
self._rubber_band.setGeometry(
|
|
1879
|
+
QRect(self._rubber_band_origin, event.pos()).normalized()
|
|
1880
|
+
)
|
|
1881
|
+
elif self._connecting_mode and self._connect_start_node:
|
|
1882
|
+
scene_pos = self.mapToScene(event.pos())
|
|
1883
|
+
start = self._connect_start_node.get_connection_point('right')
|
|
1884
|
+
|
|
1885
|
+
if not self._temp_line:
|
|
1886
|
+
self._temp_line = self.scene.addLine(
|
|
1887
|
+
start.x(), start.y(), scene_pos.x(), scene_pos.y(),
|
|
1888
|
+
QPen(QColor(THEME['accent_blue']), 2, Qt.PenStyle.DashLine)
|
|
1889
|
+
)
|
|
1890
|
+
else:
|
|
1891
|
+
self._temp_line.setLine(start.x(), start.y(), scene_pos.x(), scene_pos.y())
|
|
1892
|
+
else:
|
|
1893
|
+
super().mouseMoveEvent(event)
|
|
1894
|
+
|
|
1895
|
+
def mouseReleaseEvent(self, event: QMouseEvent):
|
|
1896
|
+
if event.button() == Qt.MouseButton.MiddleButton:
|
|
1897
|
+
self._panning = False
|
|
1898
|
+
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
1899
|
+
elif self._rubber_band and self._rubber_band.isVisible():
|
|
1900
|
+
selection_rect = self.mapToScene(self._rubber_band.geometry()).boundingRect()
|
|
1901
|
+
self._rubber_band.hide()
|
|
1902
|
+
|
|
1903
|
+
if not (event.modifiers() & Qt.KeyboardModifier.ShiftModifier):
|
|
1904
|
+
self.scene.clearSelection()
|
|
1905
|
+
|
|
1906
|
+
for node in self.nodes.values():
|
|
1907
|
+
if selection_rect.intersects(node.sceneBoundingRect()):
|
|
1908
|
+
node.setSelected(True)
|
|
1909
|
+
else:
|
|
1910
|
+
super().mouseReleaseEvent(event)
|
|
1911
|
+
|
|
1912
|
+
def keyPressEvent(self, event: QKeyEvent):
|
|
1913
|
+
move_amount = 50 # Grid step for arrow navigation
|
|
1914
|
+
|
|
1915
|
+
if event.key() == Qt.Key.Key_Escape:
|
|
1916
|
+
if self._connecting_mode:
|
|
1917
|
+
self.cancel_connection_mode()
|
|
1918
|
+
else:
|
|
1919
|
+
self.scene.clearSelection()
|
|
1920
|
+
elif event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
|
|
1921
|
+
for item in self.scene.selectedItems():
|
|
1922
|
+
if isinstance(item, VisualNode):
|
|
1923
|
+
self.delete_node(item)
|
|
1924
|
+
elif event.key() == Qt.Key.Key_Home:
|
|
1925
|
+
self.centerOn(0, 0)
|
|
1926
|
+
elif event.key() == Qt.Key.Key_0 and event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
1927
|
+
self._zoom_level = 1.0
|
|
1928
|
+
self.resetTransform()
|
|
1929
|
+
# Arrow key navigation
|
|
1930
|
+
elif event.key() == Qt.Key.Key_Up:
|
|
1931
|
+
self.verticalScrollBar().setValue(self.verticalScrollBar().value() - move_amount)
|
|
1932
|
+
elif event.key() == Qt.Key.Key_Down:
|
|
1933
|
+
self.verticalScrollBar().setValue(self.verticalScrollBar().value() + move_amount)
|
|
1934
|
+
elif event.key() == Qt.Key.Key_Left:
|
|
1935
|
+
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - move_amount)
|
|
1936
|
+
elif event.key() == Qt.Key.Key_Right:
|
|
1937
|
+
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() + move_amount)
|
|
1938
|
+
else:
|
|
1939
|
+
super().keyPressEvent(event)
|
|
1940
|
+
|
|
1941
|
+
def _find_node_at(self, item) -> Optional[VisualNode]:
|
|
1942
|
+
"""Find the VisualNode for an item"""
|
|
1943
|
+
if isinstance(item, VisualNode):
|
|
1944
|
+
return item
|
|
1945
|
+
elif item:
|
|
1946
|
+
parent = item.parentItem()
|
|
1947
|
+
while parent:
|
|
1948
|
+
if isinstance(parent, VisualNode):
|
|
1949
|
+
return parent
|
|
1950
|
+
parent = parent.parentItem()
|
|
1951
|
+
return None
|
|
1952
|
+
|
|
1953
|
+
def _create_categorized_menu(self, parent_menu: QMenu, scene_pos: QPointF):
|
|
1954
|
+
"""Create categorized node creation submenu."""
|
|
1955
|
+
for category, types in NODE_CATEGORIES.items():
|
|
1956
|
+
cat_menu = parent_menu.addMenu(category)
|
|
1957
|
+
for node_type in types:
|
|
1958
|
+
if node_type in NODE_TYPES:
|
|
1959
|
+
config = NODE_TYPES[node_type]
|
|
1960
|
+
action = cat_menu.addAction(f"{config['icon']} {config['label']}")
|
|
1961
|
+
action.setData((node_type, scene_pos))
|
|
1962
|
+
|
|
1963
|
+
def contextMenuEvent(self, event):
|
|
1964
|
+
"""Professional context menu"""
|
|
1965
|
+
scene_pos = self.mapToScene(event.pos())
|
|
1966
|
+
item = self.scene.itemAt(scene_pos, self.transform())
|
|
1967
|
+
node = self._find_node_at(item)
|
|
1968
|
+
|
|
1969
|
+
menu = QMenu(self)
|
|
1970
|
+
menu.setStyleSheet(f'''
|
|
1971
|
+
QMenu {{
|
|
1972
|
+
background-color: {THEME['bg_secondary']};
|
|
1973
|
+
border: 1px solid {THEME['border']};
|
|
1974
|
+
border-radius: 8px;
|
|
1975
|
+
padding: 8px;
|
|
1976
|
+
}}
|
|
1977
|
+
QMenu::item {{
|
|
1978
|
+
background-color: transparent;
|
|
1979
|
+
padding: 10px 24px 10px 16px;
|
|
1980
|
+
color: {THEME['text_primary']};
|
|
1981
|
+
border-radius: 4px;
|
|
1982
|
+
margin: 2px 4px;
|
|
1983
|
+
}}
|
|
1984
|
+
QMenu::item:selected {{
|
|
1985
|
+
background-color: {THEME['accent_blue']};
|
|
1986
|
+
}}
|
|
1987
|
+
QMenu::separator {{
|
|
1988
|
+
height: 1px;
|
|
1989
|
+
background: {THEME['border']};
|
|
1990
|
+
margin: 6px 8px;
|
|
1991
|
+
}}
|
|
1992
|
+
''')
|
|
1993
|
+
|
|
1994
|
+
if node:
|
|
1995
|
+
props_action = menu.addAction("Properties...")
|
|
1996
|
+
rename_action = menu.addAction("Rename")
|
|
1997
|
+
desc_action = menu.addAction("Add Description...")
|
|
1998
|
+
menu.addSeparator()
|
|
1999
|
+
|
|
2000
|
+
conn_menu = menu.addMenu("Connections")
|
|
2001
|
+
connect_action = conn_menu.addAction("Connect to...")
|
|
2002
|
+
disconnect_all = conn_menu.addAction("Disconnect All")
|
|
2003
|
+
|
|
2004
|
+
menu.addSeparator()
|
|
2005
|
+
|
|
2006
|
+
edit_menu = menu.addMenu("Edit")
|
|
2007
|
+
copy_action = edit_menu.addAction("Copy (Ctrl+C)")
|
|
2008
|
+
cut_action = edit_menu.addAction("Cut (Ctrl+X)")
|
|
2009
|
+
duplicate_action = edit_menu.addAction("Duplicate (Ctrl+D)")
|
|
2010
|
+
|
|
2011
|
+
menu.addSeparator()
|
|
2012
|
+
|
|
2013
|
+
# Code menu - different options for source nodes
|
|
2014
|
+
code_menu = menu.addMenu("Code")
|
|
2015
|
+
create_python = None
|
|
2016
|
+
create_plugin = None
|
|
2017
|
+
preview_code = None
|
|
2018
|
+
gen_cpp_h = None
|
|
2019
|
+
gen_cpp_cpp = None
|
|
2020
|
+
gen_python = None
|
|
2021
|
+
|
|
2022
|
+
if node.node_data.node_type == 'source':
|
|
2023
|
+
# Source nodes get file generation options
|
|
2024
|
+
if not node.node_data.generated_files.get('python'):
|
|
2025
|
+
create_python = code_menu.addAction("Create Python")
|
|
2026
|
+
if not node.node_data.generated_files.get('plugin'):
|
|
2027
|
+
create_plugin = code_menu.addAction("Create Plugin")
|
|
2028
|
+
|
|
2029
|
+
if node.node_data.generated_files.get('python') or node.node_data.generated_files.get('plugin'):
|
|
2030
|
+
code_menu.addSeparator()
|
|
2031
|
+
if node.node_data.generated_files.get('python'):
|
|
2032
|
+
code_menu.addAction(f"Python: {Path(node.node_data.generated_files['python']).name}").setEnabled(False)
|
|
2033
|
+
if node.node_data.generated_files.get('plugin'):
|
|
2034
|
+
code_menu.addAction(f"Plugin: {Path(node.node_data.generated_files['plugin']).name}").setEnabled(False)
|
|
2035
|
+
else:
|
|
2036
|
+
# Non-source nodes get preview options
|
|
2037
|
+
preview_code = code_menu.addAction("Preview Code...")
|
|
2038
|
+
gen_cpp_h = code_menu.addAction("Generate C++ Header")
|
|
2039
|
+
gen_cpp_cpp = code_menu.addAction("Generate C++ Source")
|
|
2040
|
+
gen_python = code_menu.addAction("Generate Python")
|
|
2041
|
+
|
|
2042
|
+
menu.addSeparator()
|
|
2043
|
+
delete_action = menu.addAction("Delete")
|
|
2044
|
+
|
|
2045
|
+
action = menu.exec(event.globalPos())
|
|
2046
|
+
|
|
2047
|
+
if action == props_action:
|
|
2048
|
+
dialog = NodePropertiesDialog(node, self)
|
|
2049
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
2050
|
+
name, desc, ntype = dialog.get_values()
|
|
2051
|
+
if name:
|
|
2052
|
+
node.update_name(name)
|
|
2053
|
+
node.update_description(desc)
|
|
2054
|
+
|
|
2055
|
+
elif action == rename_action:
|
|
2056
|
+
name, ok = QInputDialog.getText(
|
|
2057
|
+
self, "Rename", "New name:", text=node.node_data.name
|
|
2058
|
+
)
|
|
2059
|
+
if ok and name:
|
|
2060
|
+
node.update_name(name)
|
|
2061
|
+
|
|
2062
|
+
elif action == desc_action:
|
|
2063
|
+
desc, ok = QInputDialog.getMultiLineText(
|
|
2064
|
+
self, "Description", "Enter description:",
|
|
2065
|
+
text=node.node_data.description
|
|
2066
|
+
)
|
|
2067
|
+
if ok:
|
|
2068
|
+
node.update_description(desc)
|
|
2069
|
+
|
|
2070
|
+
elif action == connect_action:
|
|
2071
|
+
self.start_connection_mode(node)
|
|
2072
|
+
|
|
2073
|
+
elif action == disconnect_all:
|
|
2074
|
+
for conn in list(node.connections):
|
|
2075
|
+
self.delete_connection(conn)
|
|
2076
|
+
|
|
2077
|
+
elif action == copy_action:
|
|
2078
|
+
node.setSelected(True)
|
|
2079
|
+
self.copy_selected()
|
|
2080
|
+
|
|
2081
|
+
elif action == cut_action:
|
|
2082
|
+
node.setSelected(True)
|
|
2083
|
+
self.cut_selected()
|
|
2084
|
+
|
|
2085
|
+
elif action == duplicate_action:
|
|
2086
|
+
node.setSelected(True)
|
|
2087
|
+
self.duplicate_selected()
|
|
2088
|
+
|
|
2089
|
+
elif action == create_python and create_python:
|
|
2090
|
+
self._create_python_file(node)
|
|
2091
|
+
|
|
2092
|
+
elif action == create_plugin and create_plugin:
|
|
2093
|
+
self._create_plugin_files(node)
|
|
2094
|
+
|
|
2095
|
+
elif action == preview_code and preview_code:
|
|
2096
|
+
dialog = CodePreviewDialog(node, self)
|
|
2097
|
+
dialog.exec()
|
|
2098
|
+
|
|
2099
|
+
elif action == gen_cpp_h and gen_cpp_h:
|
|
2100
|
+
code = CodeGenerator.generate_cpp_header(node.node_data)
|
|
2101
|
+
QApplication.clipboard().setText(code)
|
|
2102
|
+
self.status_message.emit("C++ header copied to clipboard")
|
|
2103
|
+
|
|
2104
|
+
elif action == gen_cpp_cpp and gen_cpp_cpp:
|
|
2105
|
+
code = CodeGenerator.generate_cpp_source(node.node_data)
|
|
2106
|
+
QApplication.clipboard().setText(code)
|
|
2107
|
+
self.status_message.emit("C++ source copied to clipboard")
|
|
2108
|
+
|
|
2109
|
+
elif action == gen_python and gen_python:
|
|
2110
|
+
code = CodeGenerator.generate_python(node.node_data)
|
|
2111
|
+
QApplication.clipboard().setText(code)
|
|
2112
|
+
self.status_message.emit("Python code copied to clipboard")
|
|
2113
|
+
|
|
2114
|
+
elif action == delete_action:
|
|
2115
|
+
self.delete_node(node)
|
|
2116
|
+
|
|
2117
|
+
else:
|
|
2118
|
+
# Quick Source creation at top
|
|
2119
|
+
create_source_action = menu.addAction("+ Create New Source")
|
|
2120
|
+
menu.addSeparator()
|
|
2121
|
+
|
|
2122
|
+
create_menu = menu.addMenu("New")
|
|
2123
|
+
self._create_categorized_menu(create_menu, scene_pos)
|
|
2124
|
+
|
|
2125
|
+
if self._clipboard:
|
|
2126
|
+
menu.addSeparator()
|
|
2127
|
+
paste_action = menu.addAction("Paste (Ctrl+V)")
|
|
2128
|
+
|
|
2129
|
+
menu.addSeparator()
|
|
2130
|
+
|
|
2131
|
+
view_menu = menu.addMenu("View")
|
|
2132
|
+
center_action = view_menu.addAction("Center View (Home)")
|
|
2133
|
+
fit_action = view_menu.addAction("Fit All Nodes")
|
|
2134
|
+
reset_zoom_action = view_menu.addAction("Reset Zoom (Ctrl+0)")
|
|
2135
|
+
view_menu.addSeparator()
|
|
2136
|
+
toggle_grid = view_menu.addAction("Toggle Grid")
|
|
2137
|
+
toggle_grid.setCheckable(True)
|
|
2138
|
+
toggle_grid.setChecked(self._grid_visible)
|
|
2139
|
+
|
|
2140
|
+
menu.addSeparator()
|
|
2141
|
+
|
|
2142
|
+
select_menu = menu.addMenu("Selection")
|
|
2143
|
+
select_all_action = select_menu.addAction("Select All (Ctrl+A)")
|
|
2144
|
+
deselect_action = select_menu.addAction("Deselect All")
|
|
2145
|
+
|
|
2146
|
+
menu.addSeparator()
|
|
2147
|
+
|
|
2148
|
+
export_menu = menu.addMenu("Export")
|
|
2149
|
+
export_png = export_menu.addAction("Export as PNG...")
|
|
2150
|
+
export_svg = export_menu.addAction("Export as SVG...")
|
|
2151
|
+
|
|
2152
|
+
action = menu.exec(event.globalPos())
|
|
2153
|
+
|
|
2154
|
+
if action == create_source_action:
|
|
2155
|
+
name, ok = QInputDialog.getText(self, "New Source", "Source name:")
|
|
2156
|
+
if ok and name:
|
|
2157
|
+
self.add_node('source', name, scene_pos.x(), scene_pos.y())
|
|
2158
|
+
|
|
2159
|
+
elif action and hasattr(action, 'data') and action.data():
|
|
2160
|
+
data = action.data()
|
|
2161
|
+
if isinstance(data, tuple):
|
|
2162
|
+
node_type, pos = data
|
|
2163
|
+
name, ok = QInputDialog.getText(
|
|
2164
|
+
self, f"New {NODE_TYPES[node_type]['label']}", "Name:"
|
|
2165
|
+
)
|
|
2166
|
+
if ok and name:
|
|
2167
|
+
self.add_node(node_type, name, pos.x(), pos.y())
|
|
2168
|
+
|
|
2169
|
+
elif action and action.text() == "Paste (Ctrl+V)":
|
|
2170
|
+
self.paste_nodes()
|
|
2171
|
+
|
|
2172
|
+
elif action == center_action:
|
|
2173
|
+
self.centerOn(0, 0)
|
|
2174
|
+
|
|
2175
|
+
elif action == fit_action:
|
|
2176
|
+
if self.nodes:
|
|
2177
|
+
min_x = min(n.node_data.x for n in self.nodes.values())
|
|
2178
|
+
max_x = max(n.node_data.x + n.node_data.width for n in self.nodes.values())
|
|
2179
|
+
min_y = min(n.node_data.y for n in self.nodes.values())
|
|
2180
|
+
max_y = max(n.node_data.y + n.node_data.height for n in self.nodes.values())
|
|
2181
|
+
rect = QRectF(min_x - 50, min_y - 50, max_x - min_x + 100, max_y - min_y + 100)
|
|
2182
|
+
self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
|
|
2183
|
+
|
|
2184
|
+
elif action == reset_zoom_action:
|
|
2185
|
+
self._zoom_level = 1.0
|
|
2186
|
+
self.resetTransform()
|
|
2187
|
+
|
|
2188
|
+
elif action == toggle_grid:
|
|
2189
|
+
self._grid_visible = not self._grid_visible
|
|
2190
|
+
|
|
2191
|
+
elif action == select_all_action:
|
|
2192
|
+
self.select_all()
|
|
2193
|
+
|
|
2194
|
+
elif action == deselect_action:
|
|
2195
|
+
self.scene.clearSelection()
|
|
2196
|
+
|
|
2197
|
+
elif action == export_png:
|
|
2198
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
2199
|
+
self, "Export as PNG", "", "PNG Files (*.png)"
|
|
2200
|
+
)
|
|
2201
|
+
if file_path:
|
|
2202
|
+
self.export_to_image(file_path, "PNG")
|
|
2203
|
+
|
|
2204
|
+
elif action == export_svg:
|
|
2205
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
2206
|
+
self, "Export as SVG", "", "SVG Files (*.svg)"
|
|
2207
|
+
)
|
|
2208
|
+
if file_path:
|
|
2209
|
+
self.export_to_image(file_path, "SVG")
|
|
2210
|
+
|
|
2211
|
+
def _get_connected_nodes(self, source_node: VisualNode) -> List[VisualNode]:
|
|
2212
|
+
"""Get all nodes connected to this source node (traverses full graph)."""
|
|
2213
|
+
connected = []
|
|
2214
|
+
visited = set()
|
|
2215
|
+
|
|
2216
|
+
def traverse(node):
|
|
2217
|
+
if node.node_data.id in visited:
|
|
2218
|
+
return
|
|
2219
|
+
visited.add(node.node_data.id)
|
|
2220
|
+
if node != source_node:
|
|
2221
|
+
connected.append(node)
|
|
2222
|
+
for conn in node.connections:
|
|
2223
|
+
other = conn.end_node if conn.start_node == node else conn.start_node
|
|
2224
|
+
traverse(other)
|
|
2225
|
+
|
|
2226
|
+
traverse(source_node)
|
|
2227
|
+
return connected
|
|
2228
|
+
|
|
2229
|
+
def _create_python_file(self, node: VisualNode):
|
|
2230
|
+
"""Create Python file from source node with all connected nodes."""
|
|
2231
|
+
name = node.node_data.name.replace(' ', '_').lower()
|
|
2232
|
+
project_path = self._get_project_path()
|
|
2233
|
+
file_path = project_path / f"{name}.py"
|
|
2234
|
+
|
|
2235
|
+
connected = self._get_connected_nodes(node)
|
|
2236
|
+
|
|
2237
|
+
# Build code from connections
|
|
2238
|
+
code_parts = [
|
|
2239
|
+
f'"""',
|
|
2240
|
+
f'{node.node_data.name}',
|
|
2241
|
+
f'{node.node_data.description or "Generated by IncludeCPP CodeMaker"}',
|
|
2242
|
+
f'"""',
|
|
2243
|
+
''
|
|
2244
|
+
]
|
|
2245
|
+
|
|
2246
|
+
# Generate classes first
|
|
2247
|
+
for n in connected:
|
|
2248
|
+
if n.node_data.node_type in ('class', 'struct', 'interface', 'enum'):
|
|
2249
|
+
code_parts.append(CodeGenerator.generate_python(n.node_data))
|
|
2250
|
+
code_parts.append('')
|
|
2251
|
+
|
|
2252
|
+
# Then functions
|
|
2253
|
+
for n in connected:
|
|
2254
|
+
if n.node_data.node_type in ('function', 'method', 'lambda', 'constructor', 'destructor'):
|
|
2255
|
+
code_parts.append(CodeGenerator.generate_python(n.node_data))
|
|
2256
|
+
code_parts.append('')
|
|
2257
|
+
|
|
2258
|
+
# Main entry point
|
|
2259
|
+
code_parts.append('if __name__ == "__main__":')
|
|
2260
|
+
code_parts.append(' pass # TODO: Entry point')
|
|
2261
|
+
|
|
2262
|
+
try:
|
|
2263
|
+
file_path.write_text('\n'.join(code_parts), encoding='utf-8')
|
|
2264
|
+
node.node_data.generated_files['python'] = str(file_path)
|
|
2265
|
+
self.status_message.emit(f"Created: {file_path.name}")
|
|
2266
|
+
except Exception as e:
|
|
2267
|
+
self.status_message.emit(f"Error: {e}")
|
|
2268
|
+
|
|
2269
|
+
def _create_plugin_files(self, node: VisualNode):
|
|
2270
|
+
"""Create Plugin files from source node with all connected nodes."""
|
|
2271
|
+
name = node.node_data.name.replace(' ', '_').lower()
|
|
2272
|
+
project_path = self._get_project_path()
|
|
2273
|
+
connected = self._get_connected_nodes(node)
|
|
2274
|
+
|
|
2275
|
+
# Create directories
|
|
2276
|
+
plugins_dir = project_path / "plugins"
|
|
2277
|
+
include_dir = project_path / "include"
|
|
2278
|
+
plugins_dir.mkdir(exist_ok=True)
|
|
2279
|
+
include_dir.mkdir(exist_ok=True)
|
|
2280
|
+
|
|
2281
|
+
# Gather connected code
|
|
2282
|
+
classes = [n for n in connected if n.node_data.node_type in ('class', 'struct', 'interface', 'enum')]
|
|
2283
|
+
functions = [n for n in connected if n.node_data.node_type in ('function', 'method', 'constructor', 'destructor', 'lambda', 'operator')]
|
|
2284
|
+
|
|
2285
|
+
try:
|
|
2286
|
+
# .h file (include/) - declarations for all connected nodes
|
|
2287
|
+
h_parts = [
|
|
2288
|
+
'#pragma once',
|
|
2289
|
+
f'// {node.node_data.name} - Generated by IncludeCPP CodeMaker',
|
|
2290
|
+
f'// {node.node_data.description or ""}',
|
|
2291
|
+
''
|
|
2292
|
+
]
|
|
2293
|
+
for c in classes:
|
|
2294
|
+
h_parts.append(CodeGenerator.generate_cpp_header(c.node_data))
|
|
2295
|
+
h_parts.append('')
|
|
2296
|
+
for f in functions:
|
|
2297
|
+
h_parts.append(CodeGenerator.generate_cpp_header(f.node_data))
|
|
2298
|
+
h_parts.append('')
|
|
2299
|
+
|
|
2300
|
+
h_file = include_dir / f"{name}.h"
|
|
2301
|
+
h_file.write_text('\n'.join(h_parts), encoding='utf-8')
|
|
2302
|
+
|
|
2303
|
+
# .cpp file (include/) - implementations
|
|
2304
|
+
cpp_parts = [f'#include "{name}.h"', '']
|
|
2305
|
+
for c in classes:
|
|
2306
|
+
cpp_parts.append(CodeGenerator.generate_cpp_source(c.node_data))
|
|
2307
|
+
cpp_parts.append('')
|
|
2308
|
+
for f in functions:
|
|
2309
|
+
cpp_parts.append(CodeGenerator.generate_cpp_source(f.node_data))
|
|
2310
|
+
cpp_parts.append('')
|
|
2311
|
+
|
|
2312
|
+
cpp_file = include_dir / f"{name}.cpp"
|
|
2313
|
+
cpp_file.write_text('\n'.join(cpp_parts), encoding='utf-8')
|
|
2314
|
+
|
|
2315
|
+
# .cp file (plugins/) - IncludeCPP plugin entry
|
|
2316
|
+
connected_names = ', '.join(n.node_data.name for n in connected) if connected else 'None'
|
|
2317
|
+
cp_content = f'''// {node.node_data.name} - IncludeCPP Plugin
|
|
2318
|
+
// {node.node_data.description or 'Generated by CodeMaker'}
|
|
2319
|
+
// Connected elements: {connected_names}
|
|
2320
|
+
|
|
2321
|
+
#include "{name}.h"
|
|
2322
|
+
|
|
2323
|
+
void {name}_init() {{
|
|
2324
|
+
// Plugin initialization
|
|
2325
|
+
}}
|
|
2326
|
+
'''
|
|
2327
|
+
cp_file = plugins_dir / f"{name}.cp"
|
|
2328
|
+
cp_file.write_text(cp_content, encoding='utf-8')
|
|
2329
|
+
|
|
2330
|
+
node.node_data.generated_files['plugin'] = str(cp_file)
|
|
2331
|
+
self.status_message.emit(f"Created plugin: {name}.cp, {name}.h, {name}.cpp")
|
|
2332
|
+
except Exception as e:
|
|
2333
|
+
self.status_message.emit(f"Error: {e}")
|
|
2334
|
+
|
|
2335
|
+
def _get_project_path(self) -> Path:
|
|
2336
|
+
"""Get the project path from parent window."""
|
|
2337
|
+
for view in self.scene().views():
|
|
2338
|
+
if hasattr(view, 'window') and view.window():
|
|
2339
|
+
win = view.window()
|
|
2340
|
+
if hasattr(win, 'project_path'):
|
|
2341
|
+
return win.project_path
|
|
2342
|
+
return Path.cwd()
|
|
2343
|
+
|
|
2344
|
+
def align_horizontal(self):
|
|
2345
|
+
"""Align selected nodes horizontally (same Y position)."""
|
|
2346
|
+
selected = [item for item in self.scene.selectedItems() if isinstance(item, VisualNode)]
|
|
2347
|
+
if len(selected) < 2:
|
|
2348
|
+
self.status_message.emit("Select at least 2 nodes to align")
|
|
2349
|
+
return
|
|
2350
|
+
|
|
2351
|
+
# Use average Y position
|
|
2352
|
+
avg_y = sum(n.pos().y() for n in selected) / len(selected)
|
|
2353
|
+
for node in selected:
|
|
2354
|
+
node.setPos(node.pos().x(), avg_y)
|
|
2355
|
+
node.node_data.y = avg_y
|
|
2356
|
+
|
|
2357
|
+
self.status_message.emit(f"Aligned {len(selected)} nodes horizontally")
|
|
2358
|
+
|
|
2359
|
+
def align_vertical(self):
|
|
2360
|
+
"""Align selected nodes vertically (same X position)."""
|
|
2361
|
+
selected = [item for item in self.scene.selectedItems() if isinstance(item, VisualNode)]
|
|
2362
|
+
if len(selected) < 2:
|
|
2363
|
+
self.status_message.emit("Select at least 2 nodes to align")
|
|
2364
|
+
return
|
|
2365
|
+
|
|
2366
|
+
# Use average X position
|
|
2367
|
+
avg_x = sum(n.pos().x() for n in selected) / len(selected)
|
|
2368
|
+
for node in selected:
|
|
2369
|
+
node.setPos(avg_x, node.pos().y())
|
|
2370
|
+
node.node_data.x = avg_x
|
|
2371
|
+
|
|
2372
|
+
self.status_message.emit(f"Aligned {len(selected)} nodes vertically")
|
|
2373
|
+
|
|
2374
|
+
def auto_arrange(self):
|
|
2375
|
+
"""Automatically arrange all nodes in a grid layout."""
|
|
2376
|
+
if not self.nodes:
|
|
2377
|
+
return
|
|
2378
|
+
|
|
2379
|
+
nodes_list = list(self.nodes.values())
|
|
2380
|
+
n = len(nodes_list)
|
|
2381
|
+
|
|
2382
|
+
# Calculate grid dimensions
|
|
2383
|
+
cols = max(1, int(n ** 0.5))
|
|
2384
|
+
rows = (n + cols - 1) // cols
|
|
2385
|
+
|
|
2386
|
+
spacing_x = 250
|
|
2387
|
+
spacing_y = 150
|
|
2388
|
+
start_x = -((cols - 1) * spacing_x) / 2
|
|
2389
|
+
start_y = -((rows - 1) * spacing_y) / 2
|
|
2390
|
+
|
|
2391
|
+
for i, node in enumerate(nodes_list):
|
|
2392
|
+
col = i % cols
|
|
2393
|
+
row = i // cols
|
|
2394
|
+
x = start_x + col * spacing_x
|
|
2395
|
+
y = start_y + row * spacing_y
|
|
2396
|
+
node.setPos(x, y)
|
|
2397
|
+
node.node_data.x = x
|
|
2398
|
+
node.node_data.y = y
|
|
2399
|
+
|
|
2400
|
+
self.status_message.emit(f"Arranged {n} nodes in {rows}x{cols} grid")
|
|
2401
|
+
|
|
2402
|
+
def distribute_horizontal(self):
|
|
2403
|
+
"""Distribute selected nodes evenly horizontally."""
|
|
2404
|
+
selected = sorted(
|
|
2405
|
+
[item for item in self.scene.selectedItems() if isinstance(item, VisualNode)],
|
|
2406
|
+
key=lambda n: n.pos().x()
|
|
2407
|
+
)
|
|
2408
|
+
if len(selected) < 3:
|
|
2409
|
+
self.status_message.emit("Select at least 3 nodes to distribute")
|
|
2410
|
+
return
|
|
2411
|
+
|
|
2412
|
+
min_x = selected[0].pos().x()
|
|
2413
|
+
max_x = selected[-1].pos().x()
|
|
2414
|
+
spacing = (max_x - min_x) / (len(selected) - 1)
|
|
2415
|
+
|
|
2416
|
+
for i, node in enumerate(selected):
|
|
2417
|
+
new_x = min_x + i * spacing
|
|
2418
|
+
node.setPos(new_x, node.pos().y())
|
|
2419
|
+
node.node_data.x = new_x
|
|
2420
|
+
|
|
2421
|
+
self.status_message.emit(f"Distributed {len(selected)} nodes horizontally")
|
|
2422
|
+
|
|
2423
|
+
|
|
2424
|
+
# ========================================================================
|
|
2425
|
+
# Properties Panel
|
|
2426
|
+
# ========================================================================
|
|
2427
|
+
|
|
2428
|
+
class PropertiesPanel(QFrame):
|
|
2429
|
+
"""Dockable panel for editing selected node properties."""
|
|
2430
|
+
|
|
2431
|
+
property_changed = pyqtSignal(str, str, object) # node_id, property, value
|
|
2432
|
+
|
|
2433
|
+
def __init__(self, parent=None):
|
|
2434
|
+
super().__init__(parent)
|
|
2435
|
+
self.current_node = None
|
|
2436
|
+
self._setup_ui()
|
|
2437
|
+
|
|
2438
|
+
def _setup_ui(self):
|
|
2439
|
+
self.setFixedWidth(260)
|
|
2440
|
+
self.setStyleSheet(f'''
|
|
2441
|
+
QFrame {{
|
|
2442
|
+
background-color: {THEME['bg_primary']};
|
|
2443
|
+
border-left: 1px solid {THEME['border']};
|
|
2444
|
+
}}
|
|
2445
|
+
''')
|
|
2446
|
+
|
|
2447
|
+
layout = QVBoxLayout(self)
|
|
2448
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
2449
|
+
layout.setSpacing(0)
|
|
2450
|
+
|
|
2451
|
+
# Header
|
|
2452
|
+
header = QFrame()
|
|
2453
|
+
header.setFixedHeight(40)
|
|
2454
|
+
header.setStyleSheet(f'''
|
|
2455
|
+
QFrame {{
|
|
2456
|
+
background-color: {THEME['bg_secondary']};
|
|
2457
|
+
border-bottom: 1px solid {THEME['border']};
|
|
2458
|
+
}}
|
|
2459
|
+
''')
|
|
2460
|
+
header_layout = QHBoxLayout(header)
|
|
2461
|
+
header_layout.setContentsMargins(12, 0, 12, 0)
|
|
2462
|
+
|
|
2463
|
+
title = QLabel("PROPERTIES")
|
|
2464
|
+
title.setFont(QFont(SYSTEM_FONT, 9, QFont.Weight.Bold))
|
|
2465
|
+
title.setStyleSheet(f'color: {THEME["text_muted"]}; letter-spacing: 1px;')
|
|
2466
|
+
header_layout.addWidget(title)
|
|
2467
|
+
layout.addWidget(header)
|
|
2468
|
+
|
|
2469
|
+
# Content area (scrollable)
|
|
2470
|
+
scroll = QScrollArea()
|
|
2471
|
+
scroll.setWidgetResizable(True)
|
|
2472
|
+
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
2473
|
+
scroll.setStyleSheet(f'''
|
|
2474
|
+
QScrollArea {{
|
|
2475
|
+
background-color: {THEME['bg_primary']};
|
|
2476
|
+
border: none;
|
|
2477
|
+
}}
|
|
2478
|
+
''')
|
|
2479
|
+
|
|
2480
|
+
content = QWidget()
|
|
2481
|
+
content.setStyleSheet(f'background-color: {THEME["bg_primary"]};')
|
|
2482
|
+
self.content_layout = QVBoxLayout(content)
|
|
2483
|
+
self.content_layout.setContentsMargins(12, 12, 12, 12)
|
|
2484
|
+
self.content_layout.setSpacing(12)
|
|
2485
|
+
|
|
2486
|
+
# Placeholder text
|
|
2487
|
+
self.placeholder = QLabel("Select a node to edit its properties")
|
|
2488
|
+
self.placeholder.setStyleSheet(f'color: {THEME["text_muted"]}; font-style: italic;')
|
|
2489
|
+
self.placeholder.setWordWrap(True)
|
|
2490
|
+
self.content_layout.addWidget(self.placeholder)
|
|
2491
|
+
|
|
2492
|
+
# Name field
|
|
2493
|
+
self.name_label = QLabel("Name")
|
|
2494
|
+
self.name_label.setStyleSheet(f'color: {THEME["text_secondary"]}; font-size: 10px;')
|
|
2495
|
+
self.name_label.hide()
|
|
2496
|
+
self.content_layout.addWidget(self.name_label)
|
|
2497
|
+
|
|
2498
|
+
self.name_input = QLineEdit()
|
|
2499
|
+
self.name_input.setStyleSheet(f'''
|
|
2500
|
+
QLineEdit {{
|
|
2501
|
+
background: {THEME['bg_tertiary']};
|
|
2502
|
+
border: 1px solid {THEME['border']};
|
|
2503
|
+
border-radius: 4px;
|
|
2504
|
+
padding: 6px 8px;
|
|
2505
|
+
color: {THEME['text_primary']};
|
|
2506
|
+
}}
|
|
2507
|
+
QLineEdit:focus {{ border-color: {THEME['accent_blue']}; }}
|
|
2508
|
+
''')
|
|
2509
|
+
self.name_input.hide()
|
|
2510
|
+
self.name_input.textChanged.connect(self._on_name_changed)
|
|
2511
|
+
self.content_layout.addWidget(self.name_input)
|
|
2512
|
+
|
|
2513
|
+
# Type display
|
|
2514
|
+
self.type_label = QLabel("Type")
|
|
2515
|
+
self.type_label.setStyleSheet(f'color: {THEME["text_secondary"]}; font-size: 10px;')
|
|
2516
|
+
self.type_label.hide()
|
|
2517
|
+
self.content_layout.addWidget(self.type_label)
|
|
2518
|
+
|
|
2519
|
+
self.type_display = QLabel("")
|
|
2520
|
+
self.type_display.setStyleSheet(f'color: {THEME["text_primary"]}; font-weight: bold;')
|
|
2521
|
+
self.type_display.hide()
|
|
2522
|
+
self.content_layout.addWidget(self.type_display)
|
|
2523
|
+
|
|
2524
|
+
# Description field
|
|
2525
|
+
self.desc_label = QLabel("Description")
|
|
2526
|
+
self.desc_label.setStyleSheet(f'color: {THEME["text_secondary"]}; font-size: 10px;')
|
|
2527
|
+
self.desc_label.hide()
|
|
2528
|
+
self.content_layout.addWidget(self.desc_label)
|
|
2529
|
+
|
|
2530
|
+
self.desc_input = QTextEdit()
|
|
2531
|
+
self.desc_input.setMaximumHeight(80)
|
|
2532
|
+
self.desc_input.setStyleSheet(f'''
|
|
2533
|
+
QTextEdit {{
|
|
2534
|
+
background: {THEME['bg_tertiary']};
|
|
2535
|
+
border: 1px solid {THEME['border']};
|
|
2536
|
+
border-radius: 4px;
|
|
2537
|
+
padding: 6px 8px;
|
|
2538
|
+
color: {THEME['text_primary']};
|
|
2539
|
+
}}
|
|
2540
|
+
QTextEdit:focus {{ border-color: {THEME['accent_blue']}; }}
|
|
2541
|
+
''')
|
|
2542
|
+
self.desc_input.hide()
|
|
2543
|
+
self.desc_input.textChanged.connect(self._on_desc_changed)
|
|
2544
|
+
self.content_layout.addWidget(self.desc_input)
|
|
2545
|
+
|
|
2546
|
+
# Code status (for source nodes)
|
|
2547
|
+
self.code_section = QFrame()
|
|
2548
|
+
self.code_section.setStyleSheet(f'''
|
|
2549
|
+
QFrame {{
|
|
2550
|
+
background: {THEME['bg_secondary']};
|
|
2551
|
+
border-radius: 6px;
|
|
2552
|
+
padding: 8px;
|
|
2553
|
+
}}
|
|
2554
|
+
''')
|
|
2555
|
+
code_layout = QVBoxLayout(self.code_section)
|
|
2556
|
+
code_layout.setContentsMargins(8, 8, 8, 8)
|
|
2557
|
+
code_layout.setSpacing(4)
|
|
2558
|
+
|
|
2559
|
+
code_title = QLabel("CODE STATUS")
|
|
2560
|
+
code_title.setStyleSheet(f'color: {THEME["text_muted"]}; font-size: 9px;')
|
|
2561
|
+
code_layout.addWidget(code_title)
|
|
2562
|
+
|
|
2563
|
+
self.python_status = QLabel("Python: Not created")
|
|
2564
|
+
self.python_status.setStyleSheet(f'color: {THEME["text_secondary"]};')
|
|
2565
|
+
code_layout.addWidget(self.python_status)
|
|
2566
|
+
|
|
2567
|
+
self.plugin_status = QLabel("Plugin: Not created")
|
|
2568
|
+
self.plugin_status.setStyleSheet(f'color: {THEME["text_secondary"]};')
|
|
2569
|
+
code_layout.addWidget(self.plugin_status)
|
|
2570
|
+
|
|
2571
|
+
self.code_section.hide()
|
|
2572
|
+
self.content_layout.addWidget(self.code_section)
|
|
2573
|
+
|
|
2574
|
+
self.content_layout.addStretch()
|
|
2575
|
+
|
|
2576
|
+
scroll.setWidget(content)
|
|
2577
|
+
layout.addWidget(scroll)
|
|
2578
|
+
|
|
2579
|
+
def update_selection(self, node: Optional[VisualNode]):
|
|
2580
|
+
"""Update panel to show properties of selected node."""
|
|
2581
|
+
self.current_node = node
|
|
2582
|
+
|
|
2583
|
+
if node is None:
|
|
2584
|
+
self.placeholder.show()
|
|
2585
|
+
self.name_label.hide()
|
|
2586
|
+
self.name_input.hide()
|
|
2587
|
+
self.type_label.hide()
|
|
2588
|
+
self.type_display.hide()
|
|
2589
|
+
self.desc_label.hide()
|
|
2590
|
+
self.desc_input.hide()
|
|
2591
|
+
self.code_section.hide()
|
|
2592
|
+
return
|
|
2593
|
+
|
|
2594
|
+
self.placeholder.hide()
|
|
2595
|
+
|
|
2596
|
+
# Show all fields
|
|
2597
|
+
self.name_label.show()
|
|
2598
|
+
self.name_input.show()
|
|
2599
|
+
self.type_label.show()
|
|
2600
|
+
self.type_display.show()
|
|
2601
|
+
self.desc_label.show()
|
|
2602
|
+
self.desc_input.show()
|
|
2603
|
+
|
|
2604
|
+
# Update values
|
|
2605
|
+
self.name_input.blockSignals(True)
|
|
2606
|
+
self.name_input.setText(node.node_data.name)
|
|
2607
|
+
self.name_input.blockSignals(False)
|
|
2608
|
+
|
|
2609
|
+
node_info = NODE_TYPES.get(node.node_data.node_type, {})
|
|
2610
|
+
self.type_display.setText(node_info.get('label', node.node_data.node_type))
|
|
2611
|
+
|
|
2612
|
+
self.desc_input.blockSignals(True)
|
|
2613
|
+
self.desc_input.setPlainText(node.node_data.description)
|
|
2614
|
+
self.desc_input.blockSignals(False)
|
|
2615
|
+
|
|
2616
|
+
# Code status for source nodes
|
|
2617
|
+
if node.node_data.node_type == 'source':
|
|
2618
|
+
self.code_section.show()
|
|
2619
|
+
py_path = node.node_data.generated_files.get('python', '')
|
|
2620
|
+
plugin_path = node.node_data.generated_files.get('plugin', '')
|
|
2621
|
+
|
|
2622
|
+
if py_path:
|
|
2623
|
+
self.python_status.setText(f"Python: {Path(py_path).name}")
|
|
2624
|
+
self.python_status.setStyleSheet(f'color: {THEME["accent_green"]};')
|
|
2625
|
+
else:
|
|
2626
|
+
self.python_status.setText("Python: Not created")
|
|
2627
|
+
self.python_status.setStyleSheet(f'color: {THEME["text_secondary"]};')
|
|
2628
|
+
|
|
2629
|
+
if plugin_path:
|
|
2630
|
+
self.plugin_status.setText(f"Plugin: {Path(plugin_path).name}")
|
|
2631
|
+
self.plugin_status.setStyleSheet(f'color: {THEME["accent_green"]};')
|
|
2632
|
+
else:
|
|
2633
|
+
self.plugin_status.setText("Plugin: Not created")
|
|
2634
|
+
self.plugin_status.setStyleSheet(f'color: {THEME["text_secondary"]};')
|
|
2635
|
+
else:
|
|
2636
|
+
self.code_section.hide()
|
|
2637
|
+
|
|
2638
|
+
def _on_name_changed(self, text):
|
|
2639
|
+
if self.current_node and text:
|
|
2640
|
+
self.current_node.update_name(text)
|
|
2641
|
+
|
|
2642
|
+
def _on_desc_changed(self):
|
|
2643
|
+
if self.current_node:
|
|
2644
|
+
self.current_node.update_description(self.desc_input.toPlainText())
|
|
2645
|
+
|
|
2646
|
+
|
|
2647
|
+
# ========================================================================
|
|
2648
|
+
# File Tree Panel
|
|
2649
|
+
# ========================================================================
|
|
2650
|
+
|
|
2651
|
+
class FileTreePanel(QFrame):
|
|
2652
|
+
"""Professional file tree for .ma map files"""
|
|
2653
|
+
|
|
2654
|
+
file_selected = pyqtSignal(str)
|
|
2655
|
+
file_created = pyqtSignal(str)
|
|
2656
|
+
file_deleted = pyqtSignal(str)
|
|
2657
|
+
|
|
2658
|
+
def __init__(self, project_path: Path, parent=None):
|
|
2659
|
+
super().__init__(parent)
|
|
2660
|
+
self.project_path = project_path
|
|
2661
|
+
self.maps_dir = project_path / ".includecpp" / "maps"
|
|
2662
|
+
self.maps_dir.mkdir(parents=True, exist_ok=True)
|
|
2663
|
+
|
|
2664
|
+
self._setup_ui()
|
|
2665
|
+
self._load_files()
|
|
2666
|
+
|
|
2667
|
+
def _setup_ui(self):
|
|
2668
|
+
self.setStyleSheet(f'''
|
|
2669
|
+
QFrame {{
|
|
2670
|
+
background-color: {THEME['bg_primary']};
|
|
2671
|
+
border-right: 1px solid {THEME['border']};
|
|
2672
|
+
}}
|
|
2673
|
+
''')
|
|
2674
|
+
|
|
2675
|
+
layout = QVBoxLayout(self)
|
|
2676
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
2677
|
+
layout.setSpacing(0)
|
|
2678
|
+
|
|
2679
|
+
header = QFrame()
|
|
2680
|
+
header.setFixedHeight(48)
|
|
2681
|
+
header.setStyleSheet(f'''
|
|
2682
|
+
QFrame {{
|
|
2683
|
+
background-color: {THEME['bg_secondary']};
|
|
2684
|
+
border-bottom: 1px solid {THEME['border']};
|
|
2685
|
+
}}
|
|
2686
|
+
''')
|
|
2687
|
+
|
|
2688
|
+
header_layout = QHBoxLayout(header)
|
|
2689
|
+
header_layout.setContentsMargins(16, 0, 12, 0)
|
|
2690
|
+
|
|
2691
|
+
title = QLabel("Maps")
|
|
2692
|
+
title.setFont(QFont(SYSTEM_FONT, 12, QFont.Weight.Bold))
|
|
2693
|
+
title.setStyleSheet(f'color: {THEME["text_primary"]};')
|
|
2694
|
+
header_layout.addWidget(title)
|
|
2695
|
+
|
|
2696
|
+
header_layout.addStretch()
|
|
2697
|
+
|
|
2698
|
+
new_btn = QPushButton("+")
|
|
2699
|
+
new_btn.setFixedSize(28, 28)
|
|
2700
|
+
new_btn.setStyleSheet(f'''
|
|
2701
|
+
QPushButton {{
|
|
2702
|
+
background-color: {THEME['accent_blue']};
|
|
2703
|
+
border: none;
|
|
2704
|
+
border-radius: 14px;
|
|
2705
|
+
color: white;
|
|
2706
|
+
font-size: 18px;
|
|
2707
|
+
font-weight: bold;
|
|
2708
|
+
}}
|
|
2709
|
+
QPushButton:hover {{
|
|
2710
|
+
background-color: {QColor(THEME['accent_blue']).lighter(115).name()};
|
|
2711
|
+
}}
|
|
2712
|
+
''')
|
|
2713
|
+
new_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
2714
|
+
new_btn.setToolTip("Create new map")
|
|
2715
|
+
new_btn.clicked.connect(self._create_new)
|
|
2716
|
+
header_layout.addWidget(new_btn)
|
|
2717
|
+
|
|
2718
|
+
layout.addWidget(header)
|
|
2719
|
+
|
|
2720
|
+
self.tree = QTreeWidget()
|
|
2721
|
+
self.tree.setHeaderHidden(True)
|
|
2722
|
+
self.tree.setIndentation(0)
|
|
2723
|
+
self.tree.setAnimated(True)
|
|
2724
|
+
self.tree.setStyleSheet(f'''
|
|
2725
|
+
QTreeWidget {{
|
|
2726
|
+
background-color: {THEME['bg_primary']};
|
|
2727
|
+
border: none;
|
|
2728
|
+
color: {THEME['text_primary']};
|
|
2729
|
+
font-size: 12px;
|
|
2730
|
+
outline: none;
|
|
2731
|
+
}}
|
|
2732
|
+
QTreeWidget::item {{
|
|
2733
|
+
padding: 12px 16px;
|
|
2734
|
+
border-bottom: 1px solid {THEME['bg_secondary']};
|
|
2735
|
+
}}
|
|
2736
|
+
QTreeWidget::item:selected {{
|
|
2737
|
+
background-color: {THEME['accent_blue']}40;
|
|
2738
|
+
border-left: 3px solid {THEME['accent_blue']};
|
|
2739
|
+
}}
|
|
2740
|
+
QTreeWidget::item:hover:!selected {{
|
|
2741
|
+
background-color: {THEME['bg_tertiary']};
|
|
2742
|
+
}}
|
|
2743
|
+
''')
|
|
2744
|
+
self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
2745
|
+
self.tree.customContextMenuRequested.connect(self._context_menu)
|
|
2746
|
+
self.tree.itemDoubleClicked.connect(self._on_double_click)
|
|
2747
|
+
layout.addWidget(self.tree)
|
|
2748
|
+
|
|
2749
|
+
def _load_files(self):
|
|
2750
|
+
"""Load .ma files"""
|
|
2751
|
+
self.tree.clear()
|
|
2752
|
+
|
|
2753
|
+
for file_path in sorted(self.maps_dir.glob("*.ma")):
|
|
2754
|
+
item = QTreeWidgetItem([f" {file_path.stem}"])
|
|
2755
|
+
item.setData(0, Qt.ItemDataRole.UserRole, str(file_path))
|
|
2756
|
+
item.setToolTip(0, file_path.name)
|
|
2757
|
+
self.tree.addTopLevelItem(item)
|
|
2758
|
+
|
|
2759
|
+
def _create_new(self):
|
|
2760
|
+
"""Create new map file"""
|
|
2761
|
+
name, ok = QInputDialog.getText(self, "New Map", "Map name:")
|
|
2762
|
+
if ok and name:
|
|
2763
|
+
safe_name = name.replace(" ", "_").replace("/", "_")
|
|
2764
|
+
file_path = self.maps_dir / f"{safe_name}.ma"
|
|
2765
|
+
|
|
2766
|
+
map_data = MapData(name=name)
|
|
2767
|
+
file_path.write_text(json.dumps(map_data.to_dict(), indent=2), encoding='utf-8')
|
|
2768
|
+
|
|
2769
|
+
self._load_files()
|
|
2770
|
+
self.file_created.emit(str(file_path))
|
|
2771
|
+
|
|
2772
|
+
def _on_double_click(self, item, column):
|
|
2773
|
+
file_path = item.data(0, Qt.ItemDataRole.UserRole)
|
|
2774
|
+
if file_path:
|
|
2775
|
+
self.file_selected.emit(file_path)
|
|
2776
|
+
|
|
2777
|
+
def _context_menu(self, pos):
|
|
2778
|
+
item = self.tree.itemAt(pos)
|
|
2779
|
+
if not item:
|
|
2780
|
+
return
|
|
2781
|
+
|
|
2782
|
+
file_path = item.data(0, Qt.ItemDataRole.UserRole)
|
|
2783
|
+
|
|
2784
|
+
menu = QMenu(self)
|
|
2785
|
+
menu.setStyleSheet(f'''
|
|
2786
|
+
QMenu {{
|
|
2787
|
+
background-color: {THEME['bg_secondary']};
|
|
2788
|
+
border: 1px solid {THEME['border']};
|
|
2789
|
+
border-radius: 8px;
|
|
2790
|
+
padding: 4px;
|
|
2791
|
+
}}
|
|
2792
|
+
QMenu::item {{
|
|
2793
|
+
padding: 10px 20px;
|
|
2794
|
+
color: {THEME['text_primary']};
|
|
2795
|
+
border-radius: 4px;
|
|
2796
|
+
margin: 2px;
|
|
2797
|
+
}}
|
|
2798
|
+
QMenu::item:selected {{
|
|
2799
|
+
background-color: {THEME['accent_blue']};
|
|
2800
|
+
}}
|
|
2801
|
+
''')
|
|
2802
|
+
|
|
2803
|
+
open_action = menu.addAction("Open")
|
|
2804
|
+
menu.addSeparator()
|
|
2805
|
+
rename_action = menu.addAction("Rename")
|
|
2806
|
+
clear_action = menu.addAction("Clear Contents")
|
|
2807
|
+
menu.addSeparator()
|
|
2808
|
+
delete_action = menu.addAction("Delete")
|
|
2809
|
+
|
|
2810
|
+
action = menu.exec(self.tree.mapToGlobal(pos))
|
|
2811
|
+
|
|
2812
|
+
if action == open_action:
|
|
2813
|
+
self.file_selected.emit(file_path)
|
|
2814
|
+
|
|
2815
|
+
elif action == rename_action:
|
|
2816
|
+
old_name = Path(file_path).stem
|
|
2817
|
+
new_name, ok = QInputDialog.getText(
|
|
2818
|
+
self, "Rename", "New name:", text=old_name
|
|
2819
|
+
)
|
|
2820
|
+
if ok and new_name and new_name != old_name:
|
|
2821
|
+
new_path = self.maps_dir / f"{new_name}.ma"
|
|
2822
|
+
Path(file_path).rename(new_path)
|
|
2823
|
+
self._load_files()
|
|
2824
|
+
|
|
2825
|
+
elif action == clear_action:
|
|
2826
|
+
map_data = MapData(name=Path(file_path).stem)
|
|
2827
|
+
Path(file_path).write_text(json.dumps(map_data.to_dict(), indent=2), encoding='utf-8')
|
|
2828
|
+
self.file_selected.emit(file_path)
|
|
2829
|
+
|
|
2830
|
+
elif action == delete_action:
|
|
2831
|
+
reply = QMessageBox.question(
|
|
2832
|
+
self, "Delete Map",
|
|
2833
|
+
f"Are you sure you want to delete '{Path(file_path).stem}'?",
|
|
2834
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
2835
|
+
)
|
|
2836
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
2837
|
+
Path(file_path).unlink()
|
|
2838
|
+
self._load_files()
|
|
2839
|
+
self.file_deleted.emit(file_path)
|
|
2840
|
+
|
|
2841
|
+
|
|
2842
|
+
# ========================================================================
|
|
2843
|
+
# Main Project Window
|
|
2844
|
+
# ========================================================================
|
|
2845
|
+
|
|
2846
|
+
class ProjectWindow(QMainWindow):
|
|
2847
|
+
"""Professional project interface main window"""
|
|
2848
|
+
|
|
2849
|
+
def __init__(self, project_path: str = None):
|
|
2850
|
+
super().__init__()
|
|
2851
|
+
|
|
2852
|
+
self.project_path = Path(project_path) if project_path else Path.cwd()
|
|
2853
|
+
self.current_file: Optional[str] = None
|
|
2854
|
+
self._drag_pos = None
|
|
2855
|
+
self._auto_save_timer = QTimer(self)
|
|
2856
|
+
self._auto_save_timer.timeout.connect(self._auto_save)
|
|
2857
|
+
self._auto_save_timer.start(30000)
|
|
2858
|
+
|
|
2859
|
+
self._setup_window()
|
|
2860
|
+
self._setup_ui()
|
|
2861
|
+
|
|
2862
|
+
def _setup_window(self):
|
|
2863
|
+
self.setWindowTitle("IncludeCPP - CodeMaker")
|
|
2864
|
+
self.setMinimumSize(1400, 900)
|
|
2865
|
+
self.setWindowFlags(Qt.WindowType.FramelessWindowHint)
|
|
2866
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
2867
|
+
|
|
2868
|
+
def _setup_ui(self):
|
|
2869
|
+
container = QWidget()
|
|
2870
|
+
container.setStyleSheet(f'''
|
|
2871
|
+
QWidget {{
|
|
2872
|
+
background-color: {THEME['bg_primary']};
|
|
2873
|
+
border-radius: 12px;
|
|
2874
|
+
}}
|
|
2875
|
+
''')
|
|
2876
|
+
self.setCentralWidget(container)
|
|
2877
|
+
|
|
2878
|
+
main_layout = QVBoxLayout(container)
|
|
2879
|
+
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
2880
|
+
main_layout.setSpacing(0)
|
|
2881
|
+
|
|
2882
|
+
main_layout.addWidget(self._create_title_bar())
|
|
2883
|
+
main_layout.addWidget(self._create_toolbar())
|
|
2884
|
+
|
|
2885
|
+
content = QWidget()
|
|
2886
|
+
content_layout = QHBoxLayout(content)
|
|
2887
|
+
content_layout.setContentsMargins(0, 0, 0, 0)
|
|
2888
|
+
content_layout.setSpacing(0)
|
|
2889
|
+
|
|
2890
|
+
content_layout.addWidget(self._create_sidebar())
|
|
2891
|
+
|
|
2892
|
+
self.main_stack = QWidget()
|
|
2893
|
+
self.main_stack.setStyleSheet(f'background-color: {THEME["bg_dark"]};')
|
|
2894
|
+
self.main_stack_layout = QVBoxLayout(self.main_stack)
|
|
2895
|
+
self.main_stack_layout.setContentsMargins(0, 0, 0, 0)
|
|
2896
|
+
content_layout.addWidget(self.main_stack, 1)
|
|
2897
|
+
|
|
2898
|
+
main_layout.addWidget(content, 1)
|
|
2899
|
+
main_layout.addWidget(self._create_status_bar())
|
|
2900
|
+
|
|
2901
|
+
def _create_title_bar(self) -> QFrame:
|
|
2902
|
+
bar = QFrame()
|
|
2903
|
+
bar.setFixedHeight(52)
|
|
2904
|
+
bar.setStyleSheet(f'''
|
|
2905
|
+
QFrame {{
|
|
2906
|
+
background-color: {THEME['bg_secondary']};
|
|
2907
|
+
border-top-left-radius: 12px;
|
|
2908
|
+
border-top-right-radius: 12px;
|
|
2909
|
+
border-bottom: 1px solid {THEME['border']};
|
|
2910
|
+
}}
|
|
2911
|
+
''')
|
|
2912
|
+
|
|
2913
|
+
layout = QHBoxLayout(bar)
|
|
2914
|
+
layout.setContentsMargins(20, 0, 16, 0)
|
|
2915
|
+
|
|
2916
|
+
logo = QLabel("IncludeCPP")
|
|
2917
|
+
logo.setFont(QFont(SYSTEM_FONT, 13, QFont.Weight.Bold))
|
|
2918
|
+
logo.setStyleSheet(f'color: {THEME["accent_blue"]};')
|
|
2919
|
+
layout.addWidget(logo)
|
|
2920
|
+
|
|
2921
|
+
sep = QLabel("|")
|
|
2922
|
+
sep.setStyleSheet(f'color: {THEME["border"]}; margin: 0 12px;')
|
|
2923
|
+
layout.addWidget(sep)
|
|
2924
|
+
|
|
2925
|
+
self.title_label = QLabel("CodeMaker")
|
|
2926
|
+
self.title_label.setFont(QFont(SYSTEM_FONT, 11))
|
|
2927
|
+
self.title_label.setStyleSheet(f'color: {THEME["text_secondary"]};')
|
|
2928
|
+
layout.addWidget(self.title_label)
|
|
2929
|
+
|
|
2930
|
+
exp_badge = QLabel("EXPERIMENTAL")
|
|
2931
|
+
exp_badge.setFont(QFont(SYSTEM_FONT, 8, QFont.Weight.Bold))
|
|
2932
|
+
exp_badge.setStyleSheet(f'''
|
|
2933
|
+
color: {THEME["accent_orange"]};
|
|
2934
|
+
background: {THEME["accent_orange"]}20;
|
|
2935
|
+
padding: 4px 8px;
|
|
2936
|
+
border-radius: 4px;
|
|
2937
|
+
margin-left: 12px;
|
|
2938
|
+
''')
|
|
2939
|
+
layout.addWidget(exp_badge)
|
|
2940
|
+
|
|
2941
|
+
layout.addStretch()
|
|
2942
|
+
|
|
2943
|
+
for text, color, action in [
|
|
2944
|
+
("−", THEME['bg_hover'], self.showMinimized),
|
|
2945
|
+
("□", THEME['bg_hover'], self._toggle_maximize),
|
|
2946
|
+
("×", THEME['accent_red'], self.close)
|
|
2947
|
+
]:
|
|
2948
|
+
btn = QPushButton(text)
|
|
2949
|
+
btn.setFixedSize(40, 32)
|
|
2950
|
+
btn.setStyleSheet(f'''
|
|
2951
|
+
QPushButton {{
|
|
2952
|
+
background-color: transparent;
|
|
2953
|
+
border: none;
|
|
2954
|
+
border-radius: 6px;
|
|
2955
|
+
color: {THEME['text_secondary']};
|
|
2956
|
+
font-size: 16px;
|
|
2957
|
+
}}
|
|
2958
|
+
QPushButton:hover {{
|
|
2959
|
+
background-color: {color};
|
|
2960
|
+
color: {THEME['text_primary']};
|
|
2961
|
+
}}
|
|
2962
|
+
''')
|
|
2963
|
+
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
2964
|
+
btn.clicked.connect(action)
|
|
2965
|
+
layout.addWidget(btn)
|
|
2966
|
+
|
|
2967
|
+
return bar
|
|
2968
|
+
|
|
2969
|
+
def _create_toolbar(self) -> QFrame:
|
|
2970
|
+
"""Create toolbar with common actions."""
|
|
2971
|
+
toolbar = QFrame()
|
|
2972
|
+
toolbar.setFixedHeight(44)
|
|
2973
|
+
toolbar.setStyleSheet(f'''
|
|
2974
|
+
QFrame {{
|
|
2975
|
+
background-color: {THEME['bg_secondary']};
|
|
2976
|
+
border-bottom: 1px solid {THEME['border']};
|
|
2977
|
+
}}
|
|
2978
|
+
''')
|
|
2979
|
+
|
|
2980
|
+
layout = QHBoxLayout(toolbar)
|
|
2981
|
+
layout.setContentsMargins(16, 4, 16, 4)
|
|
2982
|
+
layout.setSpacing(8)
|
|
2983
|
+
|
|
2984
|
+
btn_style = f'''
|
|
2985
|
+
QPushButton {{
|
|
2986
|
+
background: {THEME['bg_tertiary']};
|
|
2987
|
+
border: 1px solid {THEME['border']};
|
|
2988
|
+
border-radius: 6px;
|
|
2989
|
+
color: {THEME['text_primary']};
|
|
2990
|
+
padding: 6px 12px;
|
|
2991
|
+
font-size: 11px;
|
|
2992
|
+
}}
|
|
2993
|
+
QPushButton:hover {{
|
|
2994
|
+
background: {THEME['bg_hover']};
|
|
2995
|
+
border-color: {THEME['accent_blue']};
|
|
2996
|
+
}}
|
|
2997
|
+
'''
|
|
2998
|
+
|
|
2999
|
+
save_btn = QPushButton("Save")
|
|
3000
|
+
save_btn.setStyleSheet(btn_style)
|
|
3001
|
+
save_btn.clicked.connect(self._save_current)
|
|
3002
|
+
layout.addWidget(save_btn)
|
|
3003
|
+
|
|
3004
|
+
layout.addWidget(self._create_separator())
|
|
3005
|
+
|
|
3006
|
+
undo_btn = QPushButton("Undo")
|
|
3007
|
+
undo_btn.setStyleSheet(btn_style)
|
|
3008
|
+
undo_btn.clicked.connect(lambda: self.canvas.undo_stack.undo() if hasattr(self, 'canvas') else None)
|
|
3009
|
+
layout.addWidget(undo_btn)
|
|
3010
|
+
|
|
3011
|
+
redo_btn = QPushButton("Redo")
|
|
3012
|
+
redo_btn.setStyleSheet(btn_style)
|
|
3013
|
+
redo_btn.clicked.connect(lambda: self.canvas.undo_stack.redo() if hasattr(self, 'canvas') else None)
|
|
3014
|
+
layout.addWidget(redo_btn)
|
|
3015
|
+
|
|
3016
|
+
layout.addWidget(self._create_separator())
|
|
3017
|
+
|
|
3018
|
+
copy_btn = QPushButton("Copy")
|
|
3019
|
+
copy_btn.setStyleSheet(btn_style)
|
|
3020
|
+
copy_btn.clicked.connect(lambda: self.canvas.copy_selected() if hasattr(self, 'canvas') else None)
|
|
3021
|
+
layout.addWidget(copy_btn)
|
|
3022
|
+
|
|
3023
|
+
paste_btn = QPushButton("Paste")
|
|
3024
|
+
paste_btn.setStyleSheet(btn_style)
|
|
3025
|
+
paste_btn.clicked.connect(lambda: self.canvas.paste_nodes() if hasattr(self, 'canvas') else None)
|
|
3026
|
+
layout.addWidget(paste_btn)
|
|
3027
|
+
|
|
3028
|
+
layout.addWidget(self._create_separator())
|
|
3029
|
+
|
|
3030
|
+
# Layout/Arrange buttons
|
|
3031
|
+
align_h_btn = QPushButton("Align H")
|
|
3032
|
+
align_h_btn.setStyleSheet(btn_style)
|
|
3033
|
+
align_h_btn.setToolTip("Align selected nodes horizontally")
|
|
3034
|
+
align_h_btn.clicked.connect(lambda: self.canvas.align_horizontal() if hasattr(self, 'canvas') else None)
|
|
3035
|
+
layout.addWidget(align_h_btn)
|
|
3036
|
+
|
|
3037
|
+
align_v_btn = QPushButton("Align V")
|
|
3038
|
+
align_v_btn.setStyleSheet(btn_style)
|
|
3039
|
+
align_v_btn.setToolTip("Align selected nodes vertically")
|
|
3040
|
+
align_v_btn.clicked.connect(lambda: self.canvas.align_vertical() if hasattr(self, 'canvas') else None)
|
|
3041
|
+
layout.addWidget(align_v_btn)
|
|
3042
|
+
|
|
3043
|
+
arrange_btn = QPushButton("Auto-Arrange")
|
|
3044
|
+
arrange_btn.setStyleSheet(btn_style)
|
|
3045
|
+
arrange_btn.setToolTip("Automatically arrange all nodes")
|
|
3046
|
+
arrange_btn.clicked.connect(lambda: self.canvas.auto_arrange() if hasattr(self, 'canvas') else None)
|
|
3047
|
+
layout.addWidget(arrange_btn)
|
|
3048
|
+
|
|
3049
|
+
layout.addWidget(self._create_separator())
|
|
3050
|
+
|
|
3051
|
+
# Quick-add buttons with accent colors
|
|
3052
|
+
source_btn = QPushButton("+ Source")
|
|
3053
|
+
source_btn.setStyleSheet(f'''
|
|
3054
|
+
QPushButton {{
|
|
3055
|
+
background: {THEME['accent_green']};
|
|
3056
|
+
border: none;
|
|
3057
|
+
border-radius: 6px;
|
|
3058
|
+
color: white;
|
|
3059
|
+
padding: 6px 12px;
|
|
3060
|
+
font-size: 11px;
|
|
3061
|
+
font-weight: bold;
|
|
3062
|
+
}}
|
|
3063
|
+
QPushButton:hover {{
|
|
3064
|
+
background: #00ff88;
|
|
3065
|
+
}}
|
|
3066
|
+
''')
|
|
3067
|
+
source_btn.clicked.connect(lambda: self._quick_add_node('source'))
|
|
3068
|
+
layout.addWidget(source_btn)
|
|
3069
|
+
|
|
3070
|
+
class_btn = QPushButton("+ Class")
|
|
3071
|
+
class_btn.setStyleSheet(f'''
|
|
3072
|
+
QPushButton {{
|
|
3073
|
+
background: {THEME['accent_blue']};
|
|
3074
|
+
border: none;
|
|
3075
|
+
border-radius: 6px;
|
|
3076
|
+
color: white;
|
|
3077
|
+
padding: 6px 12px;
|
|
3078
|
+
font-size: 11px;
|
|
3079
|
+
}}
|
|
3080
|
+
QPushButton:hover {{
|
|
3081
|
+
background: #5abeff;
|
|
3082
|
+
}}
|
|
3083
|
+
''')
|
|
3084
|
+
class_btn.clicked.connect(lambda: self._quick_add_node('class'))
|
|
3085
|
+
layout.addWidget(class_btn)
|
|
3086
|
+
|
|
3087
|
+
func_btn = QPushButton("+ Function")
|
|
3088
|
+
func_btn.setStyleSheet(f'''
|
|
3089
|
+
QPushButton {{
|
|
3090
|
+
background: #50c878;
|
|
3091
|
+
border: none;
|
|
3092
|
+
border-radius: 6px;
|
|
3093
|
+
color: white;
|
|
3094
|
+
padding: 6px 12px;
|
|
3095
|
+
font-size: 11px;
|
|
3096
|
+
}}
|
|
3097
|
+
QPushButton:hover {{
|
|
3098
|
+
background: #60d888;
|
|
3099
|
+
}}
|
|
3100
|
+
''')
|
|
3101
|
+
func_btn.clicked.connect(lambda: self._quick_add_node('function'))
|
|
3102
|
+
layout.addWidget(func_btn)
|
|
3103
|
+
|
|
3104
|
+
layout.addStretch()
|
|
3105
|
+
|
|
3106
|
+
self.search_input = QLineEdit()
|
|
3107
|
+
self.search_input.setPlaceholderText("Search... (Ctrl+F)")
|
|
3108
|
+
self.search_input.setFixedWidth(200)
|
|
3109
|
+
self.search_input.setStyleSheet(f'''
|
|
3110
|
+
QLineEdit {{
|
|
3111
|
+
background: {THEME['bg_tertiary']};
|
|
3112
|
+
border: 1px solid {THEME['border']};
|
|
3113
|
+
border-radius: 6px;
|
|
3114
|
+
padding: 6px 12px;
|
|
3115
|
+
color: {THEME['text_primary']};
|
|
3116
|
+
}}
|
|
3117
|
+
QLineEdit:focus {{
|
|
3118
|
+
border-color: {THEME['accent_blue']};
|
|
3119
|
+
}}
|
|
3120
|
+
''')
|
|
3121
|
+
self.search_input.textChanged.connect(
|
|
3122
|
+
lambda t: self.canvas.search_nodes(t) if hasattr(self, 'canvas') else None
|
|
3123
|
+
)
|
|
3124
|
+
layout.addWidget(self.search_input)
|
|
3125
|
+
|
|
3126
|
+
return toolbar
|
|
3127
|
+
|
|
3128
|
+
def _create_separator(self) -> QFrame:
|
|
3129
|
+
sep = QFrame()
|
|
3130
|
+
sep.setFixedSize(1, 24)
|
|
3131
|
+
sep.setStyleSheet(f'background: {THEME["border"]};')
|
|
3132
|
+
return sep
|
|
3133
|
+
|
|
3134
|
+
def _create_sidebar(self) -> QFrame:
|
|
3135
|
+
sidebar = QFrame()
|
|
3136
|
+
sidebar.setFixedWidth(240)
|
|
3137
|
+
sidebar.setStyleSheet(f'''
|
|
3138
|
+
QFrame {{
|
|
3139
|
+
background-color: {THEME['bg_primary']};
|
|
3140
|
+
border-right: 1px solid {THEME['border']};
|
|
3141
|
+
}}
|
|
3142
|
+
''')
|
|
3143
|
+
|
|
3144
|
+
layout = QVBoxLayout(sidebar)
|
|
3145
|
+
layout.setContentsMargins(16, 20, 16, 20)
|
|
3146
|
+
layout.setSpacing(8)
|
|
3147
|
+
|
|
3148
|
+
section = QLabel("TOOLS")
|
|
3149
|
+
section.setFont(QFont(SYSTEM_FONT, 9, QFont.Weight.Bold))
|
|
3150
|
+
section.setStyleSheet(f'color: {THEME["text_muted"]}; letter-spacing: 1px;')
|
|
3151
|
+
layout.addWidget(section)
|
|
3152
|
+
|
|
3153
|
+
layout.addSpacing(8)
|
|
3154
|
+
|
|
3155
|
+
codemaker_btn = AnimatedButton("CodeMaker", "◈", THEME['accent_blue'])
|
|
3156
|
+
codemaker_btn.clicked.connect(self._show_codemaker)
|
|
3157
|
+
layout.addWidget(codemaker_btn)
|
|
3158
|
+
|
|
3159
|
+
layout.addStretch()
|
|
3160
|
+
|
|
3161
|
+
info_frame = QFrame()
|
|
3162
|
+
info_frame.setStyleSheet(f'''
|
|
3163
|
+
QFrame {{
|
|
3164
|
+
background-color: {THEME['bg_secondary']};
|
|
3165
|
+
border-radius: 8px;
|
|
3166
|
+
padding: 12px;
|
|
3167
|
+
}}
|
|
3168
|
+
''')
|
|
3169
|
+
info_layout = QVBoxLayout(info_frame)
|
|
3170
|
+
info_layout.setContentsMargins(12, 12, 12, 12)
|
|
3171
|
+
info_layout.setSpacing(4)
|
|
3172
|
+
|
|
3173
|
+
proj_label = QLabel("PROJECT")
|
|
3174
|
+
proj_label.setFont(QFont(SYSTEM_FONT, 8))
|
|
3175
|
+
proj_label.setStyleSheet(f'color: {THEME["text_muted"]};')
|
|
3176
|
+
info_layout.addWidget(proj_label)
|
|
3177
|
+
|
|
3178
|
+
proj_name = QLabel(self.project_path.name)
|
|
3179
|
+
proj_name.setFont(QFont(SYSTEM_FONT, 10, QFont.Weight.Bold))
|
|
3180
|
+
proj_name.setStyleSheet(f'color: {THEME["text_primary"]};')
|
|
3181
|
+
proj_name.setWordWrap(True)
|
|
3182
|
+
info_layout.addWidget(proj_name)
|
|
3183
|
+
|
|
3184
|
+
layout.addWidget(info_frame)
|
|
3185
|
+
|
|
3186
|
+
return sidebar
|
|
3187
|
+
|
|
3188
|
+
def _create_status_bar(self) -> QFrame:
|
|
3189
|
+
bar = QFrame()
|
|
3190
|
+
bar.setFixedHeight(28)
|
|
3191
|
+
bar.setStyleSheet(f'''
|
|
3192
|
+
QFrame {{
|
|
3193
|
+
background-color: {THEME['bg_secondary']};
|
|
3194
|
+
border-bottom-left-radius: 12px;
|
|
3195
|
+
border-bottom-right-radius: 12px;
|
|
3196
|
+
border-top: 1px solid {THEME['border']};
|
|
3197
|
+
}}
|
|
3198
|
+
''')
|
|
3199
|
+
|
|
3200
|
+
layout = QHBoxLayout(bar)
|
|
3201
|
+
layout.setContentsMargins(16, 0, 16, 0)
|
|
3202
|
+
|
|
3203
|
+
self.status_label = QLabel("Ready")
|
|
3204
|
+
self.status_label.setFont(QFont(SYSTEM_FONT, 9))
|
|
3205
|
+
self.status_label.setStyleSheet(f'color: {THEME["text_muted"]};')
|
|
3206
|
+
layout.addWidget(self.status_label)
|
|
3207
|
+
|
|
3208
|
+
layout.addStretch()
|
|
3209
|
+
|
|
3210
|
+
self.node_count_label = QLabel("Nodes: 0")
|
|
3211
|
+
self.node_count_label.setStyleSheet(f'color: {THEME["text_muted"]}; margin-right: 16px;')
|
|
3212
|
+
layout.addWidget(self.node_count_label)
|
|
3213
|
+
|
|
3214
|
+
self.connection_count_label = QLabel("Connections: 0")
|
|
3215
|
+
self.connection_count_label.setStyleSheet(f'color: {THEME["text_muted"]}; margin-right: 16px;')
|
|
3216
|
+
layout.addWidget(self.connection_count_label)
|
|
3217
|
+
|
|
3218
|
+
self.zoom_label = QLabel("Zoom: 100%")
|
|
3219
|
+
self.zoom_label.setStyleSheet(f'color: {THEME["text_muted"]};')
|
|
3220
|
+
layout.addWidget(self.zoom_label)
|
|
3221
|
+
|
|
3222
|
+
return bar
|
|
3223
|
+
|
|
3224
|
+
def _show_codemaker(self):
|
|
3225
|
+
"""Display CodeMaker interface"""
|
|
3226
|
+
while self.main_stack_layout.count():
|
|
3227
|
+
item = self.main_stack_layout.takeAt(0)
|
|
3228
|
+
if item.widget():
|
|
3229
|
+
item.widget().deleteLater()
|
|
3230
|
+
|
|
3231
|
+
self.title_label.setText("CodeMaker")
|
|
3232
|
+
|
|
3233
|
+
codemaker = QWidget()
|
|
3234
|
+
codemaker_layout = QHBoxLayout(codemaker)
|
|
3235
|
+
codemaker_layout.setContentsMargins(0, 0, 0, 0)
|
|
3236
|
+
codemaker_layout.setSpacing(0)
|
|
3237
|
+
|
|
3238
|
+
self.file_tree = FileTreePanel(self.project_path)
|
|
3239
|
+
self.file_tree.setFixedWidth(220)
|
|
3240
|
+
self.file_tree.file_selected.connect(self._load_map)
|
|
3241
|
+
codemaker_layout.addWidget(self.file_tree)
|
|
3242
|
+
|
|
3243
|
+
self.canvas = CodeMakerCanvas()
|
|
3244
|
+
self.canvas.node_count_changed.connect(
|
|
3245
|
+
lambda n: self.node_count_label.setText(f"Nodes: {n}")
|
|
3246
|
+
)
|
|
3247
|
+
self.canvas.connection_count_changed.connect(
|
|
3248
|
+
lambda n: self.connection_count_label.setText(f"Connections: {n}")
|
|
3249
|
+
)
|
|
3250
|
+
self.canvas.status_message.connect(
|
|
3251
|
+
lambda msg: self.status_label.setText(msg)
|
|
3252
|
+
)
|
|
3253
|
+
# Wire selection changes to properties panel
|
|
3254
|
+
self.canvas.scene.selectionChanged.connect(self._on_selection_changed)
|
|
3255
|
+
codemaker_layout.addWidget(self.canvas, 1)
|
|
3256
|
+
|
|
3257
|
+
# Properties panel on the right
|
|
3258
|
+
self.properties_panel = PropertiesPanel()
|
|
3259
|
+
codemaker_layout.addWidget(self.properties_panel)
|
|
3260
|
+
|
|
3261
|
+
self.main_stack_layout.addWidget(codemaker)
|
|
3262
|
+
|
|
3263
|
+
def _on_selection_changed(self):
|
|
3264
|
+
"""Handle selection change in canvas."""
|
|
3265
|
+
if not hasattr(self, 'canvas') or not hasattr(self, 'properties_panel'):
|
|
3266
|
+
return
|
|
3267
|
+
|
|
3268
|
+
selected = [item for item in self.canvas.scene.selectedItems()
|
|
3269
|
+
if isinstance(item, VisualNode)]
|
|
3270
|
+
|
|
3271
|
+
if len(selected) == 1:
|
|
3272
|
+
self.properties_panel.update_selection(selected[0])
|
|
3273
|
+
else:
|
|
3274
|
+
self.properties_panel.update_selection(None)
|
|
3275
|
+
|
|
3276
|
+
def _load_map(self, file_path: str):
|
|
3277
|
+
"""Load a map file"""
|
|
3278
|
+
try:
|
|
3279
|
+
data = json.loads(Path(file_path).read_text(encoding='utf-8'))
|
|
3280
|
+
map_data = MapData.from_dict(data)
|
|
3281
|
+
self.canvas.load_map_data(map_data)
|
|
3282
|
+
self.current_file = file_path
|
|
3283
|
+
self.title_label.setText(f"CodeMaker - {map_data.name}")
|
|
3284
|
+
self.status_label.setText(f"Loaded: {Path(file_path).stem}")
|
|
3285
|
+
except Exception as e:
|
|
3286
|
+
QMessageBox.warning(self, "Error", f"Failed to load map: {e}")
|
|
3287
|
+
|
|
3288
|
+
def _save_current(self):
|
|
3289
|
+
"""Save current map"""
|
|
3290
|
+
if self.current_file and hasattr(self, 'canvas'):
|
|
3291
|
+
try:
|
|
3292
|
+
map_data = self.canvas.get_map_data()
|
|
3293
|
+
map_data.name = Path(self.current_file).stem
|
|
3294
|
+
Path(self.current_file).write_text(
|
|
3295
|
+
json.dumps(map_data.to_dict(), indent=2),
|
|
3296
|
+
encoding='utf-8'
|
|
3297
|
+
)
|
|
3298
|
+
self.status_label.setText(f"Saved: {map_data.name}")
|
|
3299
|
+
except Exception as e:
|
|
3300
|
+
self.status_label.setText(f"Save failed: {e}")
|
|
3301
|
+
|
|
3302
|
+
def _auto_save(self):
|
|
3303
|
+
"""Auto-save callback"""
|
|
3304
|
+
if self.current_file and hasattr(self, 'canvas'):
|
|
3305
|
+
self._save_current()
|
|
3306
|
+
|
|
3307
|
+
def _toggle_maximize(self):
|
|
3308
|
+
if self.isMaximized():
|
|
3309
|
+
self.showNormal()
|
|
3310
|
+
else:
|
|
3311
|
+
self.showMaximized()
|
|
3312
|
+
|
|
3313
|
+
def mousePressEvent(self, event):
|
|
3314
|
+
if event.button() == Qt.MouseButton.LeftButton and event.position().y() < 52:
|
|
3315
|
+
self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
|
|
3316
|
+
|
|
3317
|
+
def mouseMoveEvent(self, event):
|
|
3318
|
+
if event.buttons() == Qt.MouseButton.LeftButton and self._drag_pos:
|
|
3319
|
+
self.move(event.globalPosition().toPoint() - self._drag_pos)
|
|
3320
|
+
|
|
3321
|
+
def mouseReleaseEvent(self, event):
|
|
3322
|
+
self._drag_pos = None
|
|
3323
|
+
|
|
3324
|
+
def _quick_add_node(self, node_type: str):
|
|
3325
|
+
"""Quick-add a node from toolbar button."""
|
|
3326
|
+
if not hasattr(self, 'canvas'):
|
|
3327
|
+
return
|
|
3328
|
+
|
|
3329
|
+
name, ok = QInputDialog.getText(
|
|
3330
|
+
self, f"New {NODE_TYPES[node_type]['label']}",
|
|
3331
|
+
"Name:"
|
|
3332
|
+
)
|
|
3333
|
+
if ok and name:
|
|
3334
|
+
# Add at center of current view
|
|
3335
|
+
center = self.canvas.mapToScene(
|
|
3336
|
+
self.canvas.viewport().rect().center()
|
|
3337
|
+
)
|
|
3338
|
+
self.canvas.add_node(node_type, name, center.x(), center.y())
|
|
3339
|
+
|
|
3340
|
+
def closeEvent(self, event):
|
|
3341
|
+
self._save_current()
|
|
3342
|
+
super().closeEvent(event)
|
|
3343
|
+
|
|
3344
|
+
|
|
3345
|
+
def show_project(project_path: str = None) -> Tuple[bool, str]:
|
|
3346
|
+
"""Launch the project interface"""
|
|
3347
|
+
if not PYQT_AVAILABLE:
|
|
3348
|
+
return False, "PyQt6 not installed. Run: pip install PyQt6"
|
|
3349
|
+
|
|
3350
|
+
app = QApplication.instance()
|
|
3351
|
+
if not app:
|
|
3352
|
+
app = QApplication(sys.argv)
|
|
3353
|
+
|
|
3354
|
+
window = ProjectWindow(project_path)
|
|
3355
|
+
window.show()
|
|
3356
|
+
window._show_codemaker()
|
|
3357
|
+
|
|
3358
|
+
app.exec()
|
|
3359
|
+
return True, "Project closed"
|
|
3360
|
+
|
|
3361
|
+
|
|
3362
|
+
else:
|
|
3363
|
+
def show_project(project_path: str = None) -> Tuple[bool, str]:
|
|
3364
|
+
return False, "PyQt6 not installed. Run: pip install PyQt6"
|
|
3365
|
+
|
|
3366
|
+
|
|
3367
|
+
if __name__ == '__main__':
|
|
3368
|
+
success, msg = show_project()
|
|
3369
|
+
if not success:
|
|
3370
|
+
print(msg)
|