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.
Files changed (49) hide show
  1. includecpp/__init__.py +59 -0
  2. includecpp/__init__.pyi +255 -0
  3. includecpp/__main__.py +4 -0
  4. includecpp/cli/__init__.py +4 -0
  5. includecpp/cli/commands.py +8270 -0
  6. includecpp/cli/config_parser.py +127 -0
  7. includecpp/core/__init__.py +19 -0
  8. includecpp/core/ai_integration.py +2132 -0
  9. includecpp/core/build_manager.py +2416 -0
  10. includecpp/core/cpp_api.py +376 -0
  11. includecpp/core/cpp_api.pyi +95 -0
  12. includecpp/core/cppy_converter.py +3448 -0
  13. includecpp/core/cssl/CSSL_DOCUMENTATION.md +2075 -0
  14. includecpp/core/cssl/__init__.py +42 -0
  15. includecpp/core/cssl/cssl_builtins.py +2271 -0
  16. includecpp/core/cssl/cssl_builtins.pyi +1393 -0
  17. includecpp/core/cssl/cssl_events.py +621 -0
  18. includecpp/core/cssl/cssl_modules.py +2803 -0
  19. includecpp/core/cssl/cssl_parser.py +2575 -0
  20. includecpp/core/cssl/cssl_runtime.py +3051 -0
  21. includecpp/core/cssl/cssl_syntax.py +488 -0
  22. includecpp/core/cssl/cssl_types.py +1512 -0
  23. includecpp/core/cssl_bridge.py +882 -0
  24. includecpp/core/cssl_bridge.pyi +488 -0
  25. includecpp/core/error_catalog.py +802 -0
  26. includecpp/core/error_formatter.py +1016 -0
  27. includecpp/core/exceptions.py +97 -0
  28. includecpp/core/path_discovery.py +77 -0
  29. includecpp/core/project_ui.py +3370 -0
  30. includecpp/core/settings_ui.py +326 -0
  31. includecpp/generator/__init__.py +1 -0
  32. includecpp/generator/parser.cpp +1903 -0
  33. includecpp/generator/parser.h +281 -0
  34. includecpp/generator/type_resolver.cpp +363 -0
  35. includecpp/generator/type_resolver.h +68 -0
  36. includecpp/py.typed +0 -0
  37. includecpp/templates/cpp.proj.template +18 -0
  38. includecpp/vscode/__init__.py +1 -0
  39. includecpp/vscode/cssl/__init__.py +1 -0
  40. includecpp/vscode/cssl/language-configuration.json +38 -0
  41. includecpp/vscode/cssl/package.json +50 -0
  42. includecpp/vscode/cssl/snippets/cssl.snippets.json +1080 -0
  43. includecpp/vscode/cssl/syntaxes/cssl.tmLanguage.json +341 -0
  44. includecpp-3.7.3.dist-info/METADATA +1076 -0
  45. includecpp-3.7.3.dist-info/RECORD +49 -0
  46. includecpp-3.7.3.dist-info/WHEEL +5 -0
  47. includecpp-3.7.3.dist-info/entry_points.txt +2 -0
  48. includecpp-3.7.3.dist-info/licenses/LICENSE +21 -0
  49. 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)