diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8f73d4f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,123 @@ +# PyBreeze + +Automation-first Python IDE built on PySide6 + JEditor, integrating Web/API/GUI/Load testing into a single environment. + +## Architecture + +**Layered architecture with Facade + Strategy patterns:** + +``` +pybreeze/ +├── __init__.py # Public API facade (start_editor, plugin re-exports) +├── pybreeze_ui/ # Presentation layer (PySide6 widgets) +│ ├── editor_main/ # Main window (extends JEditor) +│ ├── menu/ # Menu bar builders (automation, install, tools, plugins) +│ ├── connect_gui/ssh/ # SSH client widgets +│ ├── extend_ai_gui/ # LLM code review & prompt editors +│ ├── jupyter_lab_gui/ # JupyterLab tab integration +│ ├── syntax/ # Automation keyword highlighting definitions +│ └── show_code_window/ # CodeWindow - output display widget +├── extend/ +│ ├── process_executor/ # Process isolation layer (Strategy pattern) +│ │ ├── python_task_process_manager.py # Core: TaskProcessManager (subprocess + thread + QTimer) +│ │ ├── process_executor_utils.py # Factory functions: build_process / start_process +│ │ ├── file_runner_process.py # FileRunnerProcess for plugin run configs +│ │ ├── api_testka/ # Each module delegates to build_process with its package name +│ │ ├── auto_control/ +│ │ ├── web_runner/ +│ │ ├── load_density/ +│ │ ├── file_automation/ +│ │ ├── mail_thunder/ +│ │ └── test_pioneer/ # TestPioneerProcess (custom variant) +│ └── mail_thunder_extend/ # Post-test email report hook +├── extend_multi_language/ # Built-in i18n (English, Traditional Chinese) +└── utils/ + ├── exception/ # Exception hierarchy (ITEException base) + ├── logging/ # pybreeze_logger + ├── file_process/ # File/directory utilities + ├── json_format/ # JSON processing + └── manager/package_manager/ # PackageManager class +``` + +**Key design patterns in use:** +- **Facade**: `pybreeze/__init__.py` exposes `start_editor()`, `EDITOR_EXTEND_TAB`, and plugin APIs +- **Strategy**: Each automation module (`api_testka`, `web_runner`, etc.) is a strategy that delegates to `TaskProcessManager` via `build_process()` +- **Template Method**: `TaskProcessManager` defines the subprocess lifecycle (start -> read stdout/stderr threads -> QTimer poll -> drain -> exit) +- **Observer**: QTimer-based polling bridges subprocess output to PySide6 UI thread via thread-safe Queues +- **Plugin System**: Auto-discovery from `jeditor_plugins/` directory; plugins register via `register()` function + +## Key types + +- `PyBreezeMainWindow` — main window class (extends JEditor), holds `tab_widget` and `current_run_code_window` +- `TaskProcessManager` — core process executor; manages subprocess, I/O threads, and QTimer UI updates +- `CodeWindow` — output display widget passed to `TaskProcessManager` +- `PackageManager` — pip wrapper for installing automation modules +- `EDITOR_EXTEND_TAB: dict` — registry for custom tabs (key=name, value=QWidget subclass) + +## Branching & CI + +- `main` branch: stable releases, publishes `pybreeze` to PyPI +- `dev` branch: development, publishes `pybreeze_dev` to PyPI +- Version config: `pyproject.toml` (stable), `dev.toml` (dev) — keep both in sync when bumping +- CI runs on GitHub Actions (Windows, Python 3.10/3.11/3.12) +- CI steps: install deps -> pytest `test/test_utils/` -> start_automation_test -> extend_automation_test + +## Development + +```bash +python -m pip install -r dev_requirements.txt +python -m pytest test/test_utils/ -v --tb=short +python -m pybreeze # launch the IDE +``` + +**Testing:** +- Unit tests: `test/test_utils/` (pure logic: exceptions, JSON, logger, file utils, package manager, venv path, jupyter helpers) +- Integration tests: `test/unit_test/start_automation/` (launches IDE in debug_mode, verifies startup and extend tab) +- Run all tests before submitting changes: `python -m pytest test/test_utils/ -v` + +## Conventions + +- Python 3.10+ — use `X | Y` union syntax, not `Union[X, Y]` +- Use `from __future__ import annotations` for deferred type evaluation +- Use `TYPE_CHECKING` guard for imports only needed by type hints (avoid circular imports) +- PySide6 threading: never update UI from worker threads — use Queue + QTimer pattern (see `TaskProcessManager`) +- Exception hierarchy: all custom exceptions inherit from `ITEException` +- Logging: use `pybreeze_logger` from `pybreeze.utils.logging.logger` +- Plugin API: `register_programming_language()` and `register_natural_language()` from `je_editor.plugins` +- Delete all unused code — do not leave dead imports, unreachable functions, commented-out blocks, or unused variables. If code is not called by any execution path, remove it entirely. No `# TODO: remove later` or `_old_` prefixes — delete immediately. + +## Security + +All code must follow secure-by-default principles. Review every change against the checklist below before committing. + +### General rules +- Never use `eval()`, `exec()`, or `pickle.loads()` on untrusted data +- Never use `subprocess.Popen(..., shell=True)` — always pass argument lists +- Never log or display secrets, tokens, passwords, or API keys +- Use `json.loads()` / `json.dumps()` for serialisation — never pickle +- Validate all user input at system boundaries (file dialogs, URL inputs, network data) + +### Network requests (SSRF prevention) +- All outbound HTTP requests must go through `diagram_net_utils.safe_download_image()` or equivalent guards +- Only `http://` and `https://` schemes are allowed — block `file://`, `ftp://`, `data:`, `gopher://` +- Resolved IP addresses must be checked against private/loopback/link-local ranges (`ipaddress.is_private`, `is_loopback`, `is_link_local`, `is_reserved`) +- Enforce download size limits (default: 20 MB) and connection timeouts (default: 15s) +- Never pass user-supplied URLs directly to `urlopen()` without validation + +### File I/O +- File read/write paths from user dialogs (`QFileDialog`) are trusted (user-initiated) +- File paths loaded from saved data (`.diagram.json`) must be validated before access: + - Local paths: check `path.is_file()` and verify extension is in an allowlist + - URLs: pass through the same SSRF validation as user-entered URLs +- Never construct file paths by string concatenation with user input — use `pathlib.Path` with validation + +### Qt / UI +- `QGraphicsTextItem` with `TextEditorInteraction` must not be enabled by default — use double-click-to-edit pattern to prevent unintended text selection issues in themed environments +- Plugin loading (`jeditor_plugins/`) uses auto-discovery — only load `.py` files, skip files starting with `_` or `.` + +## Commit & PR rules + +- Commit messages: short imperative sentence (e.g., "Update stable version", "Fix github actions") +- Do not mention any AI tools, assistants, or co-authors in commit messages or PR descriptions +- Do not add `Co-Authored-By` headers referencing any AI +- PR target: `dev` for development work, `main` for stable releases diff --git a/exe/auto_py_to_exe_setting.json b/exe/auto_py_to_exe_setting.json index 26e3fbf..a320f5d 100644 --- a/exe/auto_py_to_exe_setting.json +++ b/exe/auto_py_to_exe_setting.json @@ -63,11 +63,11 @@ }, { "optionDest": "collect_all", - "value": "ipython" + "value": "debugpy" }, { "optionDest": "collect_all", - "value": "debugpy" + "value": "ipython" } ], "nonPyinstallerOptions": { diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 888af23..cc54210 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -296,6 +296,103 @@ "file_tree_ctx_already_exists": "'{name}' already exists.", "file_tree_ctx_confirm_delete": "Confirm Delete", "file_tree_ctx_confirm_delete_message": "Are you sure you want to delete '{name}'?", + # Diagram Editor — Menu + "extend_tools_menu_diagram_editor_tab_action": "Diagram Editor Tab", + "extend_tools_menu_diagram_editor_tab_label": "Diagram Editor", + "extend_tools_menu_diagram_editor_dock_action": "Diagram Editor Dock", + "extend_tools_menu_diagram_editor_dock_title": "Diagram Editor", + # Diagram Editor — Tools + "diagram_editor_tool_select": "Select", + "diagram_editor_tool_rect": "Rect", + "diagram_editor_tool_rounded_rect": "Rounded", + "diagram_editor_tool_ellipse": "Ellipse", + "diagram_editor_tool_diamond": "Diamond", + "diagram_editor_tool_connection": "Connect", + "diagram_editor_tool_text": "Text", + # Diagram Editor — Actions + "diagram_editor_action_new": "New", + "diagram_editor_action_open": "Open", + "diagram_editor_action_save": "Save", + "diagram_editor_action_save_as": "Save As", + "diagram_editor_action_import": "Import", + "diagram_editor_action_export_png": "PNG", + "diagram_editor_action_export_svg": "SVG", + "diagram_editor_action_zoom_fit": "Fit", + "diagram_editor_action_undo": "Undo", + "diagram_editor_action_redo": "Redo", + "diagram_editor_action_grid": "Grid", + "diagram_editor_action_snap": "Snap", + # Diagram Editor — Align + "diagram_editor_align_menu": "Align", + "diagram_editor_align_left": "Align Left", + "diagram_editor_align_right": "Align Right", + "diagram_editor_align_top": "Align Top", + "diagram_editor_align_bottom": "Align Bottom", + "diagram_editor_align_center_h": "Center Horizontal", + "diagram_editor_align_center_v": "Center Vertical", + "diagram_editor_distribute_h": "Distribute Horizontal", + "diagram_editor_distribute_v": "Distribute Vertical", + # Diagram Editor — Dialogs + "diagram_editor_confirm_title": "Confirm", + "diagram_editor_confirm_new": "Discard current diagram?", + "diagram_editor_dialog_open": "Open Diagram", + "diagram_editor_dialog_save": "Save Diagram", + "diagram_editor_dialog_export_png": "Export PNG", + "diagram_editor_dialog_export_svg": "Export SVG", + "diagram_editor_error_title": "Error", + # Diagram Editor — Property Panel + "diagram_editor_prop_title": "Properties", + "diagram_editor_prop_no_selection": "No selection", + "diagram_editor_prop_node_group": "Node", + "diagram_editor_prop_conn_group": "Connection", + "diagram_editor_prop_text": "Text", + "diagram_editor_prop_width": "Width", + "diagram_editor_prop_height": "Height", + "diagram_editor_prop_shape": "Shape", + "diagram_editor_prop_fill_color": "Fill", + "diagram_editor_prop_border_color": "Border", + "diagram_editor_prop_font_size": "Font Size", + "diagram_editor_prop_label": "Label", + "diagram_editor_prop_line_style": "Style", + "diagram_editor_prop_line_color": "Color", + "diagram_editor_prop_line_width": "Width", + # Diagram Editor — Shape / Style names + "diagram_editor_shape_rectangle": "Rectangle", + "diagram_editor_shape_rounded_rect": "Rounded Rect", + "diagram_editor_shape_ellipse": "Ellipse", + "diagram_editor_shape_diamond": "Diamond", + "diagram_editor_style_solid": "Solid", + "diagram_editor_style_dashed": "Dashed", + "diagram_editor_style_dotted": "Dotted", + # Diagram Editor — Context Menu + "diagram_editor_ctx_delete": "Delete", + "diagram_editor_ctx_duplicate": "Duplicate", + "diagram_editor_ctx_bring_front": "Bring to Front", + "diagram_editor_ctx_send_back": "Send to Back", + "diagram_editor_ctx_select_all": "Select All", + "diagram_editor_ctx_paste": "Paste", + # Diagram Editor — Image + "diagram_editor_tool_image_file": "Image", + "diagram_editor_tool_image_url": "URL Image", + "diagram_editor_dialog_image_file": "Open Image", + "diagram_editor_dialog_image_url": "Image URL", + "diagram_editor_dialog_image_url_hint": "Enter image URL:", + "diagram_editor_image_load_failed": "Failed to load image.", + "diagram_editor_prop_img_group": "Image", + "diagram_editor_prop_caption": "Caption", + "diagram_editor_prop_source": "Source", + # Diagram Editor — Mermaid Import + "diagram_editor_import_title": "Import Mermaid Diagram", + "diagram_editor_import_hint": "Paste Mermaid flowchart code below:", + "diagram_editor_import_convert": "Convert", + "diagram_editor_import_cancel": "Cancel", + "diagram_editor_import_error": "Parse Error", + "diagram_editor_import_empty": "No nodes found in the input.", + # Diagram Editor — Status Bar + "diagram_editor_status_select": "Click to select, drag to move. Right-click to pan.", + "diagram_editor_status_add_node": "Click canvas to place a node.", + "diagram_editor_status_connection": "Click source node, then click target node.", + "diagram_editor_status_text": "Click canvas to place a text node.", # Plugin Browser "plugin_browser_tab_name": "Plugin Browser", "plugin_browser_repo_label": "Repository URL:", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 04fce49..a88d7e4 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -274,6 +274,103 @@ "jupyterlab_loading": "載入中...", "jupyterlab_timeout": "JupyterLab 啟動超時", "jupyterlab_init_failed": "JupyterLab 啟動失敗", + # Diagram Editor — 選單 + "extend_tools_menu_diagram_editor_tab_action": "架構圖編輯器分頁", + "extend_tools_menu_diagram_editor_tab_label": "架構圖編輯器", + "extend_tools_menu_diagram_editor_dock_action": "架構圖編輯器停駐窗格", + "extend_tools_menu_diagram_editor_dock_title": "架構圖編輯器", + # Diagram Editor — 工具 + "diagram_editor_tool_select": "選取", + "diagram_editor_tool_rect": "矩形", + "diagram_editor_tool_rounded_rect": "圓角", + "diagram_editor_tool_ellipse": "橢圓", + "diagram_editor_tool_diamond": "菱形", + "diagram_editor_tool_connection": "連線", + "diagram_editor_tool_text": "文字", + # Diagram Editor — 動作 + "diagram_editor_action_new": "新增", + "diagram_editor_action_open": "開啟", + "diagram_editor_action_save": "儲存", + "diagram_editor_action_save_as": "另存新檔", + "diagram_editor_action_import": "匯入", + "diagram_editor_action_export_png": "PNG", + "diagram_editor_action_export_svg": "SVG", + "diagram_editor_action_zoom_fit": "全覽", + "diagram_editor_action_undo": "復原", + "diagram_editor_action_redo": "重做", + "diagram_editor_action_grid": "格線", + "diagram_editor_action_snap": "對齊", + # Diagram Editor — 對齊 + "diagram_editor_align_menu": "對齊", + "diagram_editor_align_left": "靠左對齊", + "diagram_editor_align_right": "靠右對齊", + "diagram_editor_align_top": "靠上對齊", + "diagram_editor_align_bottom": "靠下對齊", + "diagram_editor_align_center_h": "水平置中", + "diagram_editor_align_center_v": "垂直置中", + "diagram_editor_distribute_h": "水平均分", + "diagram_editor_distribute_v": "垂直均分", + # Diagram Editor — 對話框 + "diagram_editor_confirm_title": "確認", + "diagram_editor_confirm_new": "是否捨棄目前的架構圖?", + "diagram_editor_dialog_open": "開啟架構圖", + "diagram_editor_dialog_save": "儲存架構圖", + "diagram_editor_dialog_export_png": "匯出 PNG", + "diagram_editor_dialog_export_svg": "匯出 SVG", + "diagram_editor_error_title": "錯誤", + # Diagram Editor — 屬性面板 + "diagram_editor_prop_title": "屬性", + "diagram_editor_prop_no_selection": "未選取", + "diagram_editor_prop_node_group": "節點", + "diagram_editor_prop_conn_group": "連線", + "diagram_editor_prop_text": "文字", + "diagram_editor_prop_width": "寬度", + "diagram_editor_prop_height": "高度", + "diagram_editor_prop_shape": "形狀", + "diagram_editor_prop_fill_color": "填充", + "diagram_editor_prop_border_color": "邊框", + "diagram_editor_prop_font_size": "字體大小", + "diagram_editor_prop_label": "標籤", + "diagram_editor_prop_line_style": "樣式", + "diagram_editor_prop_line_color": "顏色", + "diagram_editor_prop_line_width": "寬度", + # Diagram Editor — 形狀 / 樣式名稱 + "diagram_editor_shape_rectangle": "矩形", + "diagram_editor_shape_rounded_rect": "圓角矩形", + "diagram_editor_shape_ellipse": "橢圓", + "diagram_editor_shape_diamond": "菱形", + "diagram_editor_style_solid": "實線", + "diagram_editor_style_dashed": "虛線", + "diagram_editor_style_dotted": "點線", + # Diagram Editor — 右鍵選單 + "diagram_editor_ctx_delete": "刪除", + "diagram_editor_ctx_duplicate": "複製", + "diagram_editor_ctx_bring_front": "移到最前", + "diagram_editor_ctx_send_back": "移到最後", + "diagram_editor_ctx_select_all": "全選", + "diagram_editor_ctx_paste": "貼上", + # Diagram Editor — 圖片 + "diagram_editor_tool_image_file": "圖片", + "diagram_editor_tool_image_url": "網路圖片", + "diagram_editor_dialog_image_file": "開啟圖片", + "diagram_editor_dialog_image_url": "圖片網址", + "diagram_editor_dialog_image_url_hint": "輸入圖片網址:", + "diagram_editor_image_load_failed": "無法載入圖片。", + "diagram_editor_prop_img_group": "圖片", + "diagram_editor_prop_caption": "標題", + "diagram_editor_prop_source": "來源", + # Diagram Editor — Mermaid 匯入 + "diagram_editor_import_title": "匯入 Mermaid 架構圖", + "diagram_editor_import_hint": "在下方貼上 Mermaid 流程圖程式碼:", + "diagram_editor_import_convert": "轉換", + "diagram_editor_import_cancel": "取消", + "diagram_editor_import_error": "解析錯誤", + "diagram_editor_import_empty": "輸入中找不到任何節點。", + # Diagram Editor — 狀態列 + "diagram_editor_status_select": "點擊選取,拖曳移動。右鍵平移畫布。", + "diagram_editor_status_add_node": "點擊畫布放置節點。", + "diagram_editor_status_connection": "點擊來源節點,再點擊目標節點。", + "diagram_editor_status_text": "點擊畫布放置文字節點。", # File Tree Context Menu "file_tree_ctx_new_file": "新增檔案", "file_tree_ctx_new_folder": "新增資料夾", diff --git a/pybreeze/pybreeze_ui/diagram_editor/__init__.py b/pybreeze/pybreeze_ui/diagram_editor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_commands.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_commands.py new file mode 100644 index 0000000..ab3e234 --- /dev/null +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_commands.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtGui import QUndoCommand + +if TYPE_CHECKING: + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + +class DiagramSnapshotCommand(QUndoCommand): + """Snapshot-based undo/redo: stores full scene state before and after a change.""" + + def __init__(self, scene: DiagramScene, description: str, old_data: dict, new_data: dict): + super().__init__(description) + self._scene = scene + self._old_data = old_data + self._new_data = new_data + self._first_redo = True + + def redo(self) -> None: + if self._first_redo: + self._first_redo = False + return + self._scene._restore_from_dict(self._new_data) + + def undo(self) -> None: + self._scene._restore_from_dict(self._old_data) diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py new file mode 100644 index 0000000..f06e082 --- /dev/null +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -0,0 +1,559 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from PySide6.QtCore import QMarginsF, QRectF, QSizeF, Qt +from PySide6.QtGui import QImage, QKeySequence, QPainter, QShortcut +from PySide6.QtSvg import QSvgGenerator +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QFileDialog, + QFrame, + QHBoxLayout, + QLabel, + QMenu, + QMessageBox, + QPlainTextEdit, + QPushButton, + QSplitter, + QToolButton, + QVBoxLayout, + QWidget, +) +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import parse_mermaid +from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ToolMode +from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView +from pybreeze.utils.logging.logger import pybreeze_logger + + +def _lang(key: str, fallback: str = "") -> str: + return language_wrapper.language_word_dict.get(key, fallback or key) + + +_STATUS_HINTS: dict[ToolMode, str] = { + ToolMode.SELECT: "diagram_editor_status_select", + ToolMode.ADD_RECT: "diagram_editor_status_add_node", + ToolMode.ADD_ROUNDED_RECT: "diagram_editor_status_add_node", + ToolMode.ADD_ELLIPSE: "diagram_editor_status_add_node", + ToolMode.ADD_DIAMOND: "diagram_editor_status_add_node", + ToolMode.ADD_CONNECTION: "diagram_editor_status_connection", + ToolMode.ADD_TEXT: "diagram_editor_status_text", +} + + +def _make_tool_btn(text: str, checkable: bool = False) -> QToolButton: + """Create a QToolButton that always shows text (qt_material compatible).""" + btn = QToolButton() + btn.setText(text) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly) + btn.setCheckable(checkable) + btn.setMinimumWidth(60) + btn.setMinimumHeight(28) + return btn + + +def _make_action_btn(text: str) -> QPushButton: + btn = QPushButton(text) + btn.setMinimumHeight(28) + return btn + + +def _make_hsep() -> QFrame: + """Vertical separator line for toolbar groups.""" + sep = QFrame() + sep.setFrameShape(QFrame.Shape.VLine) + sep.setFrameShadow(QFrame.Shadow.Sunken) + sep.setFixedWidth(2) + return sep + + +_MERMAID_PLACEHOLDER = """\ +graph TD + A[Start] --> B{Decision} + B -->|Yes| C(Process) + B -->|No| D((End)) + C --> D +""" + + +class MermaidImportDialog(QDialog): + """Dialog for pasting Mermaid flowchart code.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(_lang("diagram_editor_import_title", "Import Mermaid Diagram")) + self.setMinimumSize(560, 420) + self.resize(600, 480) + + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(10) + + hint = QLabel(_lang("diagram_editor_import_hint", "Paste Mermaid flowchart code below:")) + layout.addWidget(hint) + + self._editor = QPlainTextEdit() + self._editor.setPlaceholderText(_MERMAID_PLACEHOLDER) + self._editor.setTabStopDistance(32) + layout.addWidget(self._editor, 1) + + btn_row = QHBoxLayout() + btn_row.addStretch() + convert_btn = QPushButton(_lang("diagram_editor_import_convert", "Convert")) + convert_btn.setDefault(True) + convert_btn.clicked.connect(self.accept) + cancel_btn = QPushButton(_lang("diagram_editor_import_cancel", "Cancel")) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(convert_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + def get_text(self) -> str: + return self._editor.toPlainText() + + +class DiagramEditorWidget(QWidget): + """Full-featured WYSIWYG architecture-diagram editor.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._current_path: Path | None = None + + # --- MVC core --- + self._scene = DiagramScene(self) + self._view = DiagramView(self._scene, self) + self._prop_panel = DiagramPropertyPanel(self._scene, self) + + # =================================================================== + # Row 1: Drawing tool modes + # =================================================================== + row1 = QHBoxLayout() + row1.setContentsMargins(8, 6, 8, 2) + row1.setSpacing(6) + + self._tool_buttons: dict[ToolMode, QToolButton] = {} + tool_defs: list[tuple[str, ToolMode]] = [ + ("diagram_editor_tool_select", ToolMode.SELECT), + ("diagram_editor_tool_rect", ToolMode.ADD_RECT), + ("diagram_editor_tool_rounded_rect", ToolMode.ADD_ROUNDED_RECT), + ("diagram_editor_tool_ellipse", ToolMode.ADD_ELLIPSE), + ("diagram_editor_tool_diamond", ToolMode.ADD_DIAMOND), + ("diagram_editor_tool_connection", ToolMode.ADD_CONNECTION), + ("diagram_editor_tool_text", ToolMode.ADD_TEXT), + ] + for lang_key, mode in tool_defs: + btn = _make_tool_btn(_lang(lang_key), checkable=True) + btn.clicked.connect(lambda checked, m=mode: self._set_mode(m)) + row1.addWidget(btn) + self._tool_buttons[mode] = btn + + row1.addWidget(_make_hsep()) + + # --- Image buttons --- + img_file_btn = _make_tool_btn(_lang("diagram_editor_tool_image_file", "Image")) + img_file_btn.clicked.connect(self._add_image_from_file) + row1.addWidget(img_file_btn) + + img_url_btn = _make_tool_btn(_lang("diagram_editor_tool_image_url", "URL Image")) + img_url_btn.clicked.connect(self._add_image_from_url) + row1.addWidget(img_url_btn) + + row1.addStretch() + + # =================================================================== + # Row 2: Actions, undo, align, grid, export, zoom + # =================================================================== + row2 = QHBoxLayout() + row2.setContentsMargins(8, 2, 8, 6) + row2.setSpacing(6) + + # --- File --- + for lang_key, handler in [ + ("diagram_editor_action_new", self._new_diagram), + ("diagram_editor_action_open", self._open_diagram), + ("diagram_editor_action_save", self._save_diagram), + ("diagram_editor_action_import", self._import_mermaid), + ]: + btn = _make_action_btn(_lang(lang_key)) + btn.clicked.connect(handler) + row2.addWidget(btn) + + row2.addWidget(_make_hsep()) + + # --- Undo / Redo --- + self._undo_btn = _make_action_btn(_lang("diagram_editor_action_undo", "Undo")) + self._undo_btn.clicked.connect(self._scene.undo_stack.undo) + self._undo_btn.setEnabled(False) + row2.addWidget(self._undo_btn) + + self._redo_btn = _make_action_btn(_lang("diagram_editor_action_redo", "Redo")) + self._redo_btn.clicked.connect(self._scene.undo_stack.redo) + self._redo_btn.setEnabled(False) + row2.addWidget(self._redo_btn) + + self._scene.undo_stack.canUndoChanged.connect(self._undo_btn.setEnabled) + self._scene.undo_stack.canRedoChanged.connect(self._redo_btn.setEnabled) + + row2.addWidget(_make_hsep()) + + # --- Align menu --- + align_btn = _make_tool_btn(_lang("diagram_editor_align_menu", "Align")) + align_menu = QMenu(self) + for lang_key, method in [ + ("diagram_editor_align_left", self._scene.align_left), + ("diagram_editor_align_right", self._scene.align_right), + ("diagram_editor_align_top", self._scene.align_top), + ("diagram_editor_align_bottom", self._scene.align_bottom), + ("diagram_editor_align_center_h", self._scene.align_center_h), + ("diagram_editor_align_center_v", self._scene.align_center_v), + ]: + align_menu.addAction(_lang(lang_key), method) + align_menu.addSeparator() + align_menu.addAction(_lang("diagram_editor_distribute_h", "Distribute H"), self._scene.distribute_h) + align_menu.addAction(_lang("diagram_editor_distribute_v", "Distribute V"), self._scene.distribute_v) + align_btn.setMenu(align_menu) + align_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + row2.addWidget(align_btn) + + row2.addWidget(_make_hsep()) + + # --- Grid / Snap --- + self._grid_cb = QCheckBox(_lang("diagram_editor_action_grid", "Grid")) + self._grid_cb.toggled.connect(self._toggle_grid) + row2.addWidget(self._grid_cb) + + self._snap_cb = QCheckBox(_lang("diagram_editor_action_snap", "Snap")) + self._snap_cb.toggled.connect(self._toggle_snap) + row2.addWidget(self._snap_cb) + + row2.addWidget(_make_hsep()) + + # --- Export --- + for lang_key, handler in [ + ("diagram_editor_action_export_png", self._export_png), + ("diagram_editor_action_export_svg", self._export_svg), + ]: + btn = _make_action_btn(_lang(lang_key)) + btn.clicked.connect(handler) + row2.addWidget(btn) + + row2.addWidget(_make_hsep()) + + # --- Zoom --- + zoom_out_btn = _make_action_btn(" - ") + zoom_out_btn.clicked.connect(self._view.zoom_out) + row2.addWidget(zoom_out_btn) + + self._zoom_label = QLabel("100%") + self._zoom_label.setMinimumWidth(48) + self._zoom_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + row2.addWidget(self._zoom_label) + + zoom_in_btn = _make_action_btn(" + ") + zoom_in_btn.clicked.connect(self._view.zoom_in) + row2.addWidget(zoom_in_btn) + + fit_btn = _make_action_btn(_lang("diagram_editor_action_zoom_fit", "Fit")) + fit_btn.clicked.connect(self._zoom_fit) + row2.addWidget(fit_btn) + + row2.addStretch() + + # =================================================================== + # Status bar + # =================================================================== + self._status_label = QLabel() + self._status_label.setContentsMargins(8, 4, 8, 4) + + # =================================================================== + # Main layout + # =================================================================== + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Toolbar container + toolbar_widget = QWidget() + toolbar_layout = QVBoxLayout(toolbar_widget) + toolbar_layout.setContentsMargins(0, 0, 0, 0) + toolbar_layout.setSpacing(0) + toolbar_layout.addLayout(row1) + toolbar_layout.addLayout(row2) + layout.addWidget(toolbar_widget) + + # Canvas + property panel + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(self._view) + splitter.addWidget(self._prop_panel) + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 0) + splitter.setSizes([800, 240]) + splitter.setCollapsible(1, True) + splitter.setHandleWidth(6) + layout.addWidget(splitter, 1) + + layout.addWidget(self._status_label) + + # =================================================================== + # Signals + # =================================================================== + self._scene.mode_changed.connect(self._on_mode_changed) + self._view.zoom_changed.connect(lambda p: self._zoom_label.setText(f"{p}%")) + self._set_mode(ToolMode.SELECT) + + # =================================================================== + # Keyboard shortcuts + # =================================================================== + self._bind_shortcuts() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _bind_shortcuts(self) -> None: + shortcuts = [ + (QKeySequence.StandardKey.Undo, self._scene.undo_stack.undo), + (QKeySequence.StandardKey.Redo, self._scene.undo_stack.redo), + (QKeySequence.StandardKey.Copy, self._scene.copy_selected), + (QKeySequence.StandardKey.Paste, self._scene.paste_clipboard), + (QKeySequence.StandardKey.SelectAll, self._scene.select_all), + (QKeySequence.StandardKey.Save, self._save_diagram), + (QKeySequence("Ctrl+Shift+S"), self._save_as_diagram), + (QKeySequence("Ctrl+D"), self._scene.duplicate_selected), + (QKeySequence("Ctrl+="), self._view.zoom_in), + (QKeySequence("Ctrl+-"), self._view.zoom_out), + (QKeySequence("Ctrl+0"), lambda: self._view.set_zoom(100)), + ] + for key, slot in shortcuts: + sc = QShortcut(key, self) + sc.activated.connect(slot) + + # ------------------------------------------------------------------ + # Tool mode + # ------------------------------------------------------------------ + + def _set_mode(self, mode: ToolMode) -> None: + self._scene.mode = mode + + def _on_mode_changed(self, mode: ToolMode) -> None: + for m, btn in self._tool_buttons.items(): + btn.setChecked(m == mode) + hint_key = _STATUS_HINTS.get(mode, "") + self._status_label.setText(_lang(hint_key, "")) + + # ------------------------------------------------------------------ + # Grid / Snap + # ------------------------------------------------------------------ + + def _toggle_grid(self, checked: bool) -> None: + self._view.draw_grid = checked + + def _toggle_snap(self, checked: bool) -> None: + self._scene.grid_enabled = checked + + # ------------------------------------------------------------------ + # File operations + # ------------------------------------------------------------------ + + def _new_diagram(self) -> None: + if self._scene.items(): + reply = QMessageBox.question( + self, + _lang("diagram_editor_confirm_title", "Confirm"), + _lang("diagram_editor_confirm_new", "Discard current diagram?"), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self._scene._clear_items() + self._scene.undo_stack.clear() + self._scene.item_count_changed.emit() + self._current_path = None + + def _open_diagram(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + _lang("diagram_editor_dialog_open", "Open Diagram"), + "", + "Diagram JSON (*.diagram.json);;All Files (*)", + ) + if not path: + return + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + self._scene.load_from_dict(data) + self._current_path = Path(path) + except Exception as e: + pybreeze_logger.error(f"Open diagram failed: {e}") + QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), str(e)) + + def _save_diagram(self) -> None: + if self._current_path is None: + self._save_as_diagram() + return + self._write_json(self._current_path) + + def _save_as_diagram(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, + _lang("diagram_editor_dialog_save", "Save Diagram"), + "untitled.diagram.json", + "Diagram JSON (*.diagram.json);;All Files (*)", + ) + if not path: + return + self._current_path = Path(path) + self._write_json(self._current_path) + + def _import_mermaid(self) -> None: + dialog = MermaidImportDialog(self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + text = dialog.get_text().strip() + if not text: + return + try: + data = parse_mermaid(text) + if not data.get("nodes"): + QMessageBox.information( + self, + _lang("diagram_editor_import_title", "Import"), + _lang("diagram_editor_import_empty", "No nodes found in the input."), + ) + return + with self._scene.undo_scope("Import Mermaid"): + self._scene._clear_items() + self._scene._load_items(data) + self._scene.item_count_changed.emit() + self._zoom_fit() + except Exception as e: + pybreeze_logger.error(f"Mermaid import failed: {e}") + QMessageBox.warning( + self, + _lang("diagram_editor_import_error", "Parse Error"), + str(e), + ) + + def _write_json(self, path: Path) -> None: + try: + data = self._scene.to_dict() + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + except Exception as e: + pybreeze_logger.error(f"Save diagram failed: {e}") + QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), str(e)) + + # ------------------------------------------------------------------ + # Export + # ------------------------------------------------------------------ + + def _get_content_rect(self) -> QRectF: + rect = self._scene.itemsBoundingRect() + return rect.marginsAdded(QMarginsF(40, 40, 40, 40)) + + def _export_png(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, _lang("diagram_editor_dialog_export_png", "Export PNG"), + "diagram.png", "PNG Image (*.png)", + ) + if not path: + return + try: + rect = self._get_content_rect() + scale = 2.0 + image = QImage( + int(rect.width() * scale), int(rect.height() * scale), + QImage.Format.Format_ARGB32_Premultiplied, + ) + image.fill(Qt.GlobalColor.white) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.scale(scale, scale) + painter.translate(-rect.topLeft()) + self._scene.clearSelection() + self._scene.render(painter, QRectF(), rect) + painter.end() + image.save(path) + except Exception as e: + pybreeze_logger.error(f"Export PNG failed: {e}") + + def _export_svg(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, _lang("diagram_editor_dialog_export_svg", "Export SVG"), + "diagram.svg", "SVG Image (*.svg)", + ) + if not path: + return + try: + rect = self._get_content_rect() + gen = QSvgGenerator() + gen.setFileName(path) + gen.setSize(QSizeF(rect.width(), rect.height()).toSize()) + gen.setViewBox(QRectF(0, 0, rect.width(), rect.height())) + painter = QPainter(gen) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.translate(-rect.topLeft()) + self._scene.clearSelection() + self._scene.render(painter, QRectF(), rect) + painter.end() + except Exception as e: + pybreeze_logger.error(f"Export SVG failed: {e}") + + # ------------------------------------------------------------------ + # Image operations + # ------------------------------------------------------------------ + + def _add_image_from_file(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + _lang("diagram_editor_dialog_image_file", "Open Image"), + "", + "Images (*.png *.jpg *.jpeg *.bmp *.gif *.svg *.webp);;All Files (*)", + ) + if not path: + return + from PySide6.QtGui import QPixmap + pix = QPixmap(path) + if pix.isNull(): + QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), + _lang("diagram_editor_image_load_failed", "Failed to load image.")) + return + self._scene.add_image(pix, path) + + def _add_image_from_url(self) -> None: + from PySide6.QtWidgets import QInputDialog + url, ok = QInputDialog.getText( + self, + _lang("diagram_editor_dialog_image_url", "Image URL"), + _lang("diagram_editor_dialog_image_url_hint", "Enter image URL:"), + ) + if not ok or not url.strip(): + return + url = url.strip() + try: + from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import safe_download_image + data = safe_download_image(url) + from PySide6.QtGui import QPixmap + pix = QPixmap() + pix.loadFromData(data) + if pix.isNull(): + raise ValueError("Invalid image data") + self._scene.add_image(pix, url) + except Exception as e: + pybreeze_logger.error(f"URL image load failed: {e}") + QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), str(e)) + + # ------------------------------------------------------------------ + # View helpers + # ------------------------------------------------------------------ + + def _zoom_fit(self) -> None: + rect = self._scene.itemsBoundingRect() + if rect.isNull(): + return + self._view.fitInView(rect.marginsAdded(QMarginsF(20, 20, 20, 20)), Qt.AspectRatioMode.KeepAspectRatio) + self._zoom_label.setText(f"{int(self._view.transform().m11() * 100)}%") diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py new file mode 100644 index 0000000..546eea8 --- /dev/null +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -0,0 +1,811 @@ +from __future__ import annotations + +import math +from enum import Enum, auto + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import ( + QBrush, + QColor, + QFont, + QPainter, + QPainterPath, + QPen, + QPixmap, + QPolygonF, +) +from PySide6.QtWidgets import ( + QGraphicsEllipseItem, + QGraphicsItem, + QGraphicsPathItem, + QGraphicsPixmapItem, + QGraphicsPolygonItem, + QGraphicsRectItem, + QGraphicsTextItem, +) + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + +class NodeShape(Enum): + RECTANGLE = auto() + ROUNDED_RECT = auto() + ELLIPSE = auto() + DIAMOND = auto() + + +class ConnectionStyle(Enum): + SOLID = auto() + DASHED = auto() + DOTTED = auto() + + +_STYLE_TO_PEN_STYLE: dict[ConnectionStyle, Qt.PenStyle] = { + ConnectionStyle.SOLID: Qt.PenStyle.SolidLine, + ConnectionStyle.DASHED: Qt.PenStyle.DashLine, + ConnectionStyle.DOTTED: Qt.PenStyle.DotLine, +} + +# --------------------------------------------------------------------------- +# Strategy: shape rendering delegated to QGraphicsItem subclasses +# --------------------------------------------------------------------------- + + +class _RectBody(QGraphicsRectItem): + def __init__(self, w: float, h: float): + super().__init__(0, 0, w, h) + self.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache) + + +class _RoundedRectBody(QGraphicsRectItem): + RADIUS = 10.0 + + def __init__(self, w: float, h: float): + super().__init__(0, 0, w, h) + self.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache) + + def paint(self, painter: QPainter, option, widget=None) -> None: + painter.setPen(self.pen()) + painter.setBrush(self.brush()) + painter.drawRoundedRect(self.rect(), self.RADIUS, self.RADIUS) + + +class _EllipseBody(QGraphicsEllipseItem): + def __init__(self, w: float, h: float): + super().__init__(0, 0, w, h) + self.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache) + + +class _DiamondBody(QGraphicsPolygonItem): + def __init__(self, w: float, h: float): + poly = QPolygonF([ + QPointF(w / 2, 0), + QPointF(w, h / 2), + QPointF(w / 2, h), + QPointF(0, h / 2), + ]) + super().__init__(poly) + self.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache) + + +_SHAPE_FACTORY: dict[NodeShape, type] = { + NodeShape.RECTANGLE: _RectBody, + NodeShape.ROUNDED_RECT: _RoundedRectBody, + NodeShape.ELLIPSE: _EllipseBody, + NodeShape.DIAMOND: _DiamondBody, +} + +# --------------------------------------------------------------------------- +# Visual constants +# --------------------------------------------------------------------------- + +_DEFAULT_NODE_W = 140.0 +_DEFAULT_NODE_H = 60.0 +_NODE_PEN_COLOR = "#455a64" +_NODE_BRUSH_COLOR = "#e3f2fd" +_NODE_SELECTED_COLOR = "#1565c0" +_LABEL_FONT_FAMILY = "Segoe UI" +_LABEL_FONT_SIZE = 10 +_CONNECTION_COLOR = "#37474f" +_CONNECTION_WIDTH = 2.0 +_ARROW_SIZE = 10.0 +_HANDLE_SIZE = 8.0 + + +# --------------------------------------------------------------------------- +# Editable label — only enters edit mode on double-click +# --------------------------------------------------------------------------- + +class _EditableLabel(QGraphicsTextItem): + """Label that is read-only by default; double-click to edit, focus-out to commit.""" + + def focusOutEvent(self, event) -> None: + self.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + tc = self.textCursor() + tc.clearSelection() + self.setTextCursor(tc) + parent = self.parentItem() + if parent is not None and hasattr(parent, "_center_label"): + parent._center_label() + super().focusOutEvent(event) + + def mouseDoubleClickEvent(self, event) -> None: + self.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction) + self.setFocus(Qt.FocusReason.MouseFocusReason) + super().mouseDoubleClickEvent(event) + + +# --------------------------------------------------------------------------- +# Resize handles +# --------------------------------------------------------------------------- + +_HANDLE_CURSORS: dict[str, Qt.CursorShape] = { + "tl": Qt.CursorShape.SizeFDiagCursor, + "tr": Qt.CursorShape.SizeBDiagCursor, + "bl": Qt.CursorShape.SizeBDiagCursor, + "br": Qt.CursorShape.SizeFDiagCursor, +} + + +class ResizeHandle(QGraphicsRectItem): + """Draggable corner handle for node resizing.""" + + def __init__(self, role: str, parent_node: DiagramNode): + hs = _HANDLE_SIZE + super().__init__(-hs / 2, -hs / 2, hs, hs, parent_node) + self.role = role + self._parent_node = parent_node + self.setBrush(QBrush(QColor(_NODE_SELECTED_COLOR))) + self.setPen(QPen(QColor("#0d47a1"), 1)) + self.setCursor(_HANDLE_CURSORS.get(role, Qt.CursorShape.SizeAllCursor)) + self.setAcceptHoverEvents(True) + self.setVisible(False) + self.setZValue(100) + self._drag_start: QPointF | None = None + self._orig_rect: QRectF | None = None + self._orig_pos: QPointF | None = None + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self._drag_start = event.scenePos() + self._orig_rect = QRectF(0, 0, self._parent_node.node_w, self._parent_node.node_h) + self._orig_pos = QPointF(self._parent_node.pos()) + self._parent_node._resizing = True + scene = self.scene() + if scene and hasattr(scene, "begin_undo"): + scene.begin_undo("Resize") + event.accept() + else: + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: + if self._drag_start is None: + return + delta = event.scenePos() - self._drag_start + self._parent_node._apply_resize(self.role, delta, self._orig_rect, self._orig_pos) + event.accept() + + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton and self._drag_start is not None: + self._drag_start = None + self._parent_node._resizing = False + scene = self.scene() + if scene and hasattr(scene, "end_undo"): + scene.end_undo() + event.accept() + else: + super().mouseReleaseEvent(event) + + +# --------------------------------------------------------------------------- +# DiagramNode +# --------------------------------------------------------------------------- + +class DiagramNode(QGraphicsRectItem): + """Composite node: invisible bounding rect holds a shape body + centred label + resize handles.""" + + # Class-level grid config (set by DiagramScene) + grid_enabled: bool = False + grid_size: int = 20 + + def __init__( + self, + x: float = 0, + y: float = 0, + w: float = _DEFAULT_NODE_W, + h: float = _DEFAULT_NODE_H, + text: str = "Node", + shape: NodeShape = NodeShape.RECTANGLE, + fill_color: str | None = None, + border_color: str | None = None, + font_size: int = _LABEL_FONT_SIZE, + ): + super().__init__(0, 0, w, h) + self.setPen(QPen(Qt.PenStyle.NoPen)) + self.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + self.setPos(x, y) + self.setFlags( + QGraphicsItem.GraphicsItemFlag.ItemIsMovable + | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable + | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges + ) + self.node_w = w + self.node_h = h + self.shape_type = shape + self.connections: list[DiagramConnection] = [] + self._resizing = False + + # Colors + self._fill_color = QColor(fill_color) if fill_color else QColor(_NODE_BRUSH_COLOR) + self._border_color = QColor(border_color) if border_color else QColor(_NODE_PEN_COLOR) + self._font_size = font_size + + # Shape body (child) + self.body: QGraphicsItem = self._make_body(w, h) + + # Label (child) — read-only by default; double-click to edit + self.label = _EditableLabel(text, self) + self.label.setFont(QFont(_LABEL_FONT_FAMILY, self._font_size)) + self.label.setDefaultTextColor(QColor("#212121")) + self.label.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + self._center_label() + + # Resize handles (children) + self._handles: dict[str, ResizeHandle] = {} + for role in ("tl", "tr", "bl", "br"): + self._handles[role] = ResizeHandle(role, self) + self._update_handles() + + # --- body factory --- + + def _make_body(self, w: float, h: float) -> QGraphicsItem: + body_cls = _SHAPE_FACTORY[self.shape_type] + body = body_cls(w, h) + body.setParentItem(self) + body.setPen(QPen(self._border_color, 2)) + body.setBrush(QBrush(self._fill_color)) + return body + + def _rebuild_body(self) -> None: + if self.body.scene(): + self.body.scene().removeItem(self.body) + else: + self.body.setParentItem(None) + self.body = self._make_body(self.node_w, self.node_h) + + # --- size / geometry --- + + def set_size(self, w: float, h: float) -> None: + w = max(40.0, w) + h = max(20.0, h) + self.prepareGeometryChange() + self.node_w = w + self.node_h = h + self.setRect(0, 0, w, h) + self._rebuild_body() + self._center_label() + self._update_handles() + for conn in self.connections: + conn.update_path() + + def _apply_resize(self, role: str, delta: QPointF, orig_rect: QRectF, orig_pos: QPointF) -> None: + new_w = orig_rect.width() + new_h = orig_rect.height() + new_x = orig_pos.x() + new_y = orig_pos.y() + + if "r" in role: + new_w = orig_rect.width() + delta.x() + if "l" in role: + new_w = orig_rect.width() - delta.x() + new_x = orig_pos.x() + delta.x() + if "b" in role: + new_h = orig_rect.height() + delta.y() + if "t" in role: + new_h = orig_rect.height() - delta.y() + new_y = orig_pos.y() + delta.y() + + # Clamp minimum + if new_w < 40: + if "l" in role: + new_x = orig_pos.x() + orig_rect.width() - 40 + new_w = 40 + if new_h < 20: + if "t" in role: + new_y = orig_pos.y() + orig_rect.height() - 20 + new_h = 20 + + self.setPos(new_x, new_y) + self.prepareGeometryChange() + self.node_w = new_w + self.node_h = new_h + self.setRect(0, 0, new_w, new_h) + self._rebuild_body() + self._center_label() + self._update_handles() + for conn in self.connections: + conn.update_path() + + def _center_label(self) -> None: + br = self.label.boundingRect() + self.label.setPos( + (self.node_w - br.width()) / 2, + (self.node_h - br.height()) / 2, + ) + + def _update_handles(self) -> None: + positions = { + "tl": QPointF(0, 0), + "tr": QPointF(self.node_w, 0), + "bl": QPointF(0, self.node_h), + "br": QPointF(self.node_w, self.node_h), + } + for role, handle in self._handles.items(): + handle.setPos(positions[role]) + + def _show_handles(self, visible: bool) -> None: + for h in self._handles.values(): + h.setVisible(visible) + + # --- geometry helpers --- + + def center_pos(self) -> QPointF: + return self.pos() + QPointF(self.node_w / 2, self.node_h / 2) + + def edge_point(self, target: QPointF) -> QPointF: + """Return the intersection of the line from center→target with the node boundary.""" + center = self.center_pos() + dx = target.x() - center.x() + dy = target.y() - center.y() + if dx == 0 and dy == 0: + return center + + hw, hh = self.node_w / 2, self.node_h / 2 + + if self.shape_type == NodeShape.ELLIPSE: + angle = math.atan2(dy, dx) + return center + QPointF(hw * math.cos(angle), hh * math.sin(angle)) + + if self.shape_type == NodeShape.DIAMOND: + denom = abs(dx) / hw + abs(dy) / hh + if denom < 1e-9: + return center + scale = 1.0 / denom + return center + QPointF(dx * scale, dy * scale) + + # Rectangle / Rounded Rect + adx, ady = abs(dx), abs(dy) + if adx < 1e-9: + return center + QPointF(0, hh if dy > 0 else -hh) + if ady < 1e-9: + return center + QPointF(hw if dx > 0 else -hw, 0) + if adx * hh > ady * hw: + scale = hw / adx + else: + scale = hh / ady + return center + QPointF(dx * scale, dy * scale) + + # --- text --- + + def text(self) -> str: + return self.label.toPlainText() + + def set_text(self, text: str) -> None: + self.label.setPlainText(text) + self._center_label() + + # --- style setters --- + + def set_fill_color(self, color: QColor) -> None: + self._fill_color = color + self.body.setBrush(QBrush(color)) + + def set_border_color(self, color: QColor) -> None: + self._border_color = color + self.body.setPen(QPen(color, 2)) + + def set_font_size(self, size: int) -> None: + self._font_size = max(6, min(size, 48)) + self.label.setFont(QFont(_LABEL_FONT_FAMILY, self._font_size)) + self._center_label() + + def set_shape(self, shape: NodeShape) -> None: + if shape == self.shape_type: + return + self.shape_type = shape + self._rebuild_body() + self._center_label() + + # --- overrides --- + + def itemChange(self, change, value): + if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: + if DiagramNode.grid_enabled and not self._resizing: + gs = DiagramNode.grid_size + if gs > 0: + x = round(value.x() / gs) * gs + y = round(value.y() / gs) * gs + return QPointF(x, y) + if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + for conn in self.connections: + conn.update_path() + if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged: + selected = self.isSelected() + pen = QPen(QColor(_NODE_SELECTED_COLOR), 2.5) if selected else QPen(self._border_color, 2) + self.body.setPen(pen) + self._show_handles(selected) + return super().itemChange(change, value) + + # --- serialisation --- + + def to_dict(self, node_id: int) -> dict: + return { + "id": node_id, + "x": self.pos().x(), + "y": self.pos().y(), + "w": self.node_w, + "h": self.node_h, + "text": self.text(), + "shape": self.shape_type.name, + "fill_color": self._fill_color.name(), + "border_color": self._border_color.name(), + "font_size": self._font_size, + } + + @classmethod + def from_dict(cls, data: dict) -> DiagramNode: + return cls( + x=data["x"], + y=data["y"], + w=data.get("w", _DEFAULT_NODE_W), + h=data.get("h", _DEFAULT_NODE_H), + text=data.get("text", "Node"), + shape=NodeShape[data.get("shape", "RECTANGLE")], + fill_color=data.get("fill_color", data.get("color")), + border_color=data.get("border_color"), + font_size=data.get("font_size", _LABEL_FONT_SIZE), + ) + + +# --------------------------------------------------------------------------- +# DiagramConnection +# --------------------------------------------------------------------------- + +class DiagramConnection(QGraphicsPathItem): + """Directed edge drawn as a cubic bezier with arrowhead, connecting node borders.""" + + def __init__( + self, + source: DiagramNode, + target: DiagramNode, + label: str = "", + line_color: str | None = None, + line_width: float = _CONNECTION_WIDTH, + style: ConnectionStyle = ConnectionStyle.SOLID, + ): + super().__init__() + self.source = source + self.target = target + self._line_color = QColor(line_color) if line_color else QColor(_CONNECTION_COLOR) + self._line_width = line_width + self._style = style + self._apply_pen() + self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) + self.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache) + self.setZValue(-1) + + source.connections.append(self) + target.connections.append(self) + + self._label_item: QGraphicsTextItem | None = None + if label: + self._create_label(label) + + self.update_path() + + def _apply_pen(self) -> None: + pen = QPen(self._line_color, self._line_width) + pen.setStyle(_STYLE_TO_PEN_STYLE.get(self._style, Qt.PenStyle.SolidLine)) + self.setPen(pen) + + def _create_label(self, text: str) -> None: + self._label_item = _EditableLabel(text, self) + self._label_item.setFont(QFont(_LABEL_FONT_FAMILY, 8)) + self._label_item.setDefaultTextColor(QColor("#616161")) + self._label_item.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + + # --- style setters --- + + def set_line_color(self, color: QColor) -> None: + self._line_color = color + self._apply_pen() + + def set_line_width(self, width: float) -> None: + self._line_width = max(0.5, min(width, 10.0)) + self._apply_pen() + + def set_style(self, style: ConnectionStyle) -> None: + self._style = style + self._apply_pen() + + def edge_label(self) -> str: + return self._label_item.toPlainText() if self._label_item else "" + + def set_edge_label(self, text: str) -> None: + if text and self._label_item is None: + self._create_label(text) + elif self._label_item is not None: + self._label_item.setPlainText(text) + self.update_path() + + # --- path calculation with edge intersection --- + + def update_path(self) -> None: + sc = self.source.center_pos() + tc = self.target.center_pos() + sp = self.source.edge_point(tc) + tp = self.target.edge_point(sc) + + dx = tp.x() - sp.x() + dy = tp.y() - sp.y() + dist = math.hypot(dx, dy) + offset = min(dist * 0.3, 60.0) if dist > 1e-3 else 0 + + # Control points: orient along dominant axis + if abs(dx) >= abs(dy): + sign = 1 if dx >= 0 else -1 + c1 = QPointF(sp.x() + offset * sign, sp.y()) + c2 = QPointF(tp.x() - offset * sign, tp.y()) + else: + sign = 1 if dy >= 0 else -1 + c1 = QPointF(sp.x(), sp.y() + offset * sign) + c2 = QPointF(tp.x(), tp.y() - offset * sign) + + path = QPainterPath(sp) + path.cubicTo(c1, c2, tp) + + # Arrowhead + arrow_ref = c2 if dist > 1e-3 else sc + angle = math.atan2(tp.y() - arrow_ref.y(), tp.x() - arrow_ref.x()) + p1 = tp - QPointF( + math.cos(angle - math.pi / 6) * _ARROW_SIZE, + math.sin(angle - math.pi / 6) * _ARROW_SIZE, + ) + p2 = tp - QPointF( + math.cos(angle + math.pi / 6) * _ARROW_SIZE, + math.sin(angle + math.pi / 6) * _ARROW_SIZE, + ) + path.moveTo(tp) + path.lineTo(p1) + path.moveTo(tp) + path.lineTo(p2) + + self.setPath(path) + + # Edge label at midpoint + if self._label_item is not None: + mid = QPointF((sp.x() + tp.x()) / 2, (sp.y() + tp.y()) / 2) + br = self._label_item.boundingRect() + self._label_item.setPos(mid.x() - br.width() / 2, mid.y() - br.height() - 4) + + # --- overrides --- + + def itemChange(self, change, value): + if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged: + color = QColor(_NODE_SELECTED_COLOR) if self.isSelected() else self._line_color + pen = QPen(color, self._line_width + (0.5 if self.isSelected() else 0)) + pen.setStyle(_STYLE_TO_PEN_STYLE.get(self._style, Qt.PenStyle.SolidLine)) + self.setPen(pen) + return super().itemChange(change, value) + + def detach(self) -> None: + if self in self.source.connections: + self.source.connections.remove(self) + if self in self.target.connections: + self.target.connections.remove(self) + + # --- serialisation --- + + def to_dict(self, node_map: dict[DiagramNode, int]) -> dict: + return { + "source": node_map[self.source], + "target": node_map[self.target], + "label": self.edge_label(), + "line_color": self._line_color.name(), + "line_width": self._line_width, + "style": self._style.name, + } + + +# --------------------------------------------------------------------------- +# DiagramImage — draggable image on the canvas +# --------------------------------------------------------------------------- + +_IMG_BORDER_PEN = QPen(QColor("#90a4ae"), 1) +_IMG_SELECTED_PEN = QPen(QColor(_NODE_SELECTED_COLOR), 2) + + +class DiagramImage(QGraphicsRectItem): + """A movable, resizable image item. + + Stores the *source* (local path or URL string) so the diagram can be + saved and reloaded. The actual pixel data is held in a child + ``QGraphicsPixmapItem``. + """ + + grid_enabled: bool = False + grid_size: int = 20 + + def __init__( + self, + x: float = 0, + y: float = 0, + w: float = 200, + h: float = 200, + source: str = "", + pixmap: QPixmap | None = None, + ): + super().__init__(0, 0, w, h) + self.setPen(_IMG_BORDER_PEN) + self.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + self.setPos(x, y) + self.setFlags( + QGraphicsItem.GraphicsItemFlag.ItemIsMovable + | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable + | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges + ) + self.img_w = w + self.img_h = h + self._source = source + self._resizing = False + + # Optional caption + self.label = _EditableLabel("", self) + self.label.setFont(QFont(_LABEL_FONT_FAMILY, 9)) + self.label.setDefaultTextColor(QColor("#424242")) + self.label.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + + # Pixmap child — cached for GPU compositing + self._pix_item = QGraphicsPixmapItem(self) + self._pix_item.setTransformationMode(Qt.TransformationMode.SmoothTransformation) + self._pix_item.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache) + if pixmap and not pixmap.isNull(): + self._apply_pixmap(pixmap) + + # Resize handles + self._handles: dict[str, ResizeHandle] = {} + for role in ("tl", "tr", "bl", "br"): + self._handles[role] = ResizeHandle(role, self) + self._update_handles() + self.connections: list[DiagramConnection] = [] + + # --- pixmap --- + + def _apply_pixmap(self, pixmap: QPixmap) -> None: + scaled = pixmap.scaled( + int(self.img_w), int(self.img_h), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self._pix_item.setPixmap(scaled) + # Centre pixmap inside bounding rect + self._pix_item.setPos( + (self.img_w - scaled.width()) / 2, + (self.img_h - scaled.height()) / 2, + ) + + def set_pixmap(self, pixmap: QPixmap, source: str = "") -> None: + if source: + self._source = source + if pixmap.isNull(): + return + self._apply_pixmap(pixmap) + + def source(self) -> str: + return self._source + + # --- geometry --- + + def center_pos(self) -> QPointF: + return self.pos() + QPointF(self.img_w / 2, self.img_h / 2) + + def set_size(self, w: float, h: float) -> None: + w, h = max(40.0, w), max(40.0, h) + self.prepareGeometryChange() + self.img_w, self.img_h = w, h + self.setRect(0, 0, w, h) + pix = self._pix_item.pixmap() + if pix and not pix.isNull(): + self._apply_pixmap(QPixmap(pix)) + self._center_label() + self._update_handles() + + def _apply_resize(self, role, delta, orig_rect, orig_pos): + new_w, new_h = orig_rect.width(), orig_rect.height() + new_x, new_y = orig_pos.x(), orig_pos.y() + if "r" in role: + new_w = orig_rect.width() + delta.x() + if "l" in role: + new_w = orig_rect.width() - delta.x() + new_x = orig_pos.x() + delta.x() + if "b" in role: + new_h = orig_rect.height() + delta.y() + if "t" in role: + new_h = orig_rect.height() - delta.y() + new_y = orig_pos.y() + delta.y() + if new_w < 40: + if "l" in role: + new_x = orig_pos.x() + orig_rect.width() - 40 + new_w = 40 + if new_h < 40: + if "t" in role: + new_y = orig_pos.y() + orig_rect.height() - 40 + new_h = 40 + self.setPos(new_x, new_y) + self.set_size(new_w, new_h) + + def _center_label(self) -> None: + br = self.label.boundingRect() + self.label.setPos( + (self.img_w - br.width()) / 2, + self.img_h + 2, + ) + + def _update_handles(self) -> None: + positions = { + "tl": QPointF(0, 0), "tr": QPointF(self.img_w, 0), + "bl": QPointF(0, self.img_h), "br": QPointF(self.img_w, self.img_h), + } + for role, h in self._handles.items(): + h.setPos(positions[role]) + + def _show_handles(self, visible: bool) -> None: + for h in self._handles.values(): + h.setVisible(visible) + + def text(self) -> str: + return self.label.toPlainText() + + def set_text(self, text: str) -> None: + self.label.setPlainText(text) + self._center_label() + + # --- overrides --- + + def itemChange(self, change, value): + if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: + if DiagramImage.grid_enabled and not self._resizing: + gs = DiagramImage.grid_size + if gs > 0: + return QPointF(round(value.x() / gs) * gs, round(value.y() / gs) * gs) + if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged: + self.setPen(_IMG_SELECTED_PEN if self.isSelected() else _IMG_BORDER_PEN) + self._show_handles(self.isSelected()) + return super().itemChange(change, value) + + # --- serialisation --- + + def to_dict(self, img_id: int) -> dict: + return { + "id": img_id, + "x": self.pos().x(), + "y": self.pos().y(), + "w": self.img_w, + "h": self.img_h, + "source": self._source, + "caption": self.text(), + } + + @classmethod + def from_dict(cls, data: dict) -> DiagramImage: + img = cls( + x=data["x"], y=data["y"], + w=data.get("w", 200), h=data.get("h", 200), + source=data.get("source", ""), + ) + caption = data.get("caption", "") + if caption: + img.set_text(caption) + return img diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py new file mode 100644 index 0000000..ec154d2 --- /dev/null +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +import re +from collections import defaultdict, deque +from dataclasses import dataclass + +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + ConnectionStyle, + NodeShape, +) + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class _NodeInfo: + id: str + text: str + shape: NodeShape + x: float = 0.0 + y: float = 0.0 + + +@dataclass +class _EdgeInfo: + source: str + target: str + label: str = "" + style: ConnectionStyle = ConnectionStyle.SOLID + line_width: float = 2.0 + + +# --------------------------------------------------------------------------- +# Regex patterns +# --------------------------------------------------------------------------- + +_DIRECTION_RE = re.compile( + r"^\s*(?:graph|flowchart)\s+(TD|TB|LR|RL|BT)", re.IGNORECASE +) +_COMMENT_RE = re.compile(r"%%.*$") +_SKIP_RE = re.compile( + r"^\s*(?:subgraph|end\b|style\b|classDef\b|class\s|click\b|linkStyle\b)", + re.IGNORECASE, +) + +# Arrow operators with optional pipe-label. +# Order matters: longer patterns first to avoid partial matches. +_ARROW_SPLIT_RE = re.compile( + r"\s*" + r"(" + r"={2,}>(?:\|[^|]*\|)?" # ==> or ==>|label| + r"|-\.+->(?:\|[^|]*\|)?" # -.-> or -.->|label| + r"|--+>(?:\|[^|]*\|)?" # --> or -->|label| + r"|={2,}" # === + r"|-\.+-" # -.- + r"|---+" # --- + r")" + r"\s*" +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _normalize_inline_labels(line: str) -> str: + """Convert ``-- label -->`` style to ``-->|label|`` pipe style.""" + line = re.sub(r"--\s+(\S[^|]*?)\s+-->", r"-->|\1|", line) + line = re.sub(r"-\.\s+(\S[^|]*?)\s+\.->", r"-.->|\1|", line) + line = re.sub(r"==\s+(\S[^|]*?)\s+==>", r"==>|\1|", line) + return line + + +def _parse_node_ref(raw: str, nodes: dict[str, _NodeInfo]) -> str | None: + """Parse ``ID[text]`` / ``ID(text)`` / ``ID{text}`` / ``ID((text))`` and + register in *nodes*. Returns the node ID or ``None``.""" + raw = raw.strip() + if not raw: + return None + + m = re.match(r"(\w+)(.*)", raw, re.DOTALL) + if not m: + return None + + node_id = m.group(1) + rest = m.group(2).strip() + + text = node_id + shape = NodeShape.RECTANGLE + + if rest.startswith("((") and rest.endswith("))"): + text, shape = rest[2:-2].strip(), NodeShape.ELLIPSE + elif rest.startswith("([") and rest.endswith("])"): + text, shape = rest[2:-2].strip(), NodeShape.ROUNDED_RECT + elif rest.startswith("(") and rest.endswith(")"): + text, shape = rest[1:-1].strip(), NodeShape.ROUNDED_RECT + elif rest.startswith("{{") and rest.endswith("}}"): + text, shape = rest[2:-2].strip(), NodeShape.DIAMOND + elif rest.startswith("{") and rest.endswith("}"): + text, shape = rest[1:-1].strip(), NodeShape.DIAMOND + elif rest.startswith("[[") and rest.endswith("]]"): + text, shape = rest[2:-2].strip(), NodeShape.RECTANGLE + elif rest.startswith("[") and rest.endswith("]"): + text, shape = rest[1:-1].strip(), NodeShape.RECTANGLE + + if node_id not in nodes: + nodes[node_id] = _NodeInfo(id=node_id, text=text, shape=shape) + + return node_id + + +def _parse_arrow(token: str) -> tuple[str, ConnectionStyle, float]: + """Return ``(label, style, line_width)`` from an arrow token.""" + label = "" + lm = re.search(r"\|([^|]*)\|", token) + if lm: + label = lm.group(1).strip() + + if "==" in token: + return label, ConnectionStyle.SOLID, 3.5 + if "-." in token: + return label, ConnectionStyle.DASHED, 2.0 + return label, ConnectionStyle.SOLID, 2.0 + + +# --------------------------------------------------------------------------- +# Auto-layout (layered BFS) +# --------------------------------------------------------------------------- + + +def _auto_layout( + nodes: dict[str, _NodeInfo], + edges: list[_EdgeInfo], + direction: str, +) -> None: + if not nodes: + return + + adj: dict[str, list[str]] = defaultdict(list) + in_deg: dict[str, int] = {nid: 0 for nid in nodes} + + for e in edges: + if e.source in nodes and e.target in nodes: + adj[e.source].append(e.target) + in_deg[e.target] = in_deg.get(e.target, 0) + 1 + + # BFS from roots + roots = [nid for nid, deg in in_deg.items() if deg == 0] + if not roots: + roots = [next(iter(nodes))] + + layers: dict[str, int] = {} + queue: deque[str] = deque() + for r in roots: + layers[r] = 0 + queue.append(r) + + while queue: + nid = queue.popleft() + for child in adj.get(nid, []): + new_layer = layers[nid] + 1 + if child not in layers or layers[child] < new_layer: + layers[child] = new_layer + queue.append(child) + + max_layer = max(layers.values(), default=0) + for nid in nodes: + if nid not in layers: + max_layer += 1 + layers[nid] = max_layer + + # Group by layer + layer_groups: dict[int, list[str]] = defaultdict(list) + for nid, layer in layers.items(): + layer_groups[layer].append(nid) + + # Estimate node width from text length + def _node_w(nid: str) -> float: + return max(100.0, min(len(nodes[nid].text) * 11 + 40, 300.0)) + + node_h = 60.0 + gap_main = 120.0 # between layers + gap_cross = 80.0 # between siblings + + horizontal = direction in ("LR", "RL") + flip = direction in ("RL", "BT") + + for layer_idx in sorted(layer_groups.keys()): + group = layer_groups[layer_idx] + count = len(group) + + for i, nid in enumerate(group): + nw = _node_w(nid) + cross_offset = (i - (count - 1) / 2) + + if horizontal: + main_pos = layer_idx * (200 + gap_main) + cross_pos = cross_offset * (node_h + gap_cross) + if flip: + main_pos = -main_pos + nodes[nid].x = main_pos + nodes[nid].y = cross_pos + else: + main_pos = layer_idx * (node_h + gap_main) + cross_pos = cross_offset * (200 + gap_cross) + if flip: + main_pos = -main_pos + nodes[nid].x = cross_pos + nodes[nid].y = main_pos + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def parse_mermaid(text: str) -> dict: + """Parse Mermaid flowchart syntax and return a diagram dict with + auto-layout positions. + + Supported subset:: + + graph TD + A[Rectangle] --> B(Rounded Rect) + B --> C{Diamond} + C -->|Yes| D((Ellipse)) + C -->|No| E[End] + A -- label --> B + X -.-> Y + P ==> Q + """ + nodes: dict[str, _NodeInfo] = {} + edges: list[_EdgeInfo] = [] + direction = "TD" + + for raw_line in text.splitlines(): + line = _COMMENT_RE.sub("", raw_line).strip() + if not line: + continue + + # Direction header + dm = _DIRECTION_RE.match(line) + if dm: + direction = dm.group(1).upper() + if direction == "TB": + direction = "TD" + continue + + # Skip unsupported directives + if _SKIP_RE.match(line): + continue + + # Handle semicolon-separated statements on one line + for stmt in line.split(";"): + stmt = stmt.strip() + if not stmt: + continue + + stmt = _normalize_inline_labels(stmt) + + parts = _ARROW_SPLIT_RE.split(stmt) + parts = [p for p in parts if p.strip()] + + if len(parts) < 3: + # Standalone node definition + if parts: + _parse_node_ref(parts[0], nodes) + continue + + # Chained edges: N0 arrow N1 arrow N2 ... + idx = 0 + while idx + 2 < len(parts): + src_id = _parse_node_ref(parts[idx], nodes) + label, style, width = _parse_arrow(parts[idx + 1]) + tgt_id = _parse_node_ref(parts[idx + 2], nodes) + + if src_id and tgt_id: + edges.append(_EdgeInfo( + source=src_id, target=tgt_id, + label=label, style=style, line_width=width, + )) + idx += 2 + + # Auto-layout + _auto_layout(nodes, edges, direction) + + # Build output dict (same format as DiagramScene.to_dict) + node_list = list(nodes.values()) + id_to_idx: dict[str, int] = {n.id: i for i, n in enumerate(node_list)} + + def _node_w(n: _NodeInfo) -> float: + return max(100.0, min(len(n.text) * 11 + 40, 300.0)) + + return { + "nodes": [ + { + "id": i, + "x": n.x, + "y": n.y, + "w": _node_w(n), + "h": 60.0, + "text": n.text, + "shape": n.shape.name, + } + for i, n in enumerate(node_list) + ], + "connections": [ + { + "source": id_to_idx[e.source], + "target": id_to_idx[e.target], + "label": e.label, + "style": e.style.name, + "line_width": e.line_width, + } + for e in edges + if e.source in id_to_idx and e.target in id_to_idx + ], + } diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py new file mode 100644 index 0000000..3671748 --- /dev/null +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py @@ -0,0 +1,80 @@ +"""Network utilities for the diagram editor with security hardening. + +Security measures: + - Only ``http`` and ``https`` schemes are allowed (blocks ``file://``, ``ftp://``, etc.) + - Resolved IPs are checked against private/loopback ranges to prevent SSRF + - Downloads are capped at ``MAX_DOWNLOAD_BYTES`` to prevent memory exhaustion + - Connection timeout is enforced +""" +from __future__ import annotations + +import ipaddress +import socket +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024 # 20 MB +TIMEOUT_SECONDS = 15 +_ALLOWED_SCHEMES = {"http", "https"} + + +class ImageDownloadError(Exception): + pass + + +def _validate_url(url: str) -> str: + """Validate URL scheme and resolve hostname to block private/loopback IPs.""" + parsed = urlparse(url) + + # Scheme check + if parsed.scheme.lower() not in _ALLOWED_SCHEMES: + raise ImageDownloadError( + f"Scheme '{parsed.scheme}' is not allowed. Use http or https." + ) + + # Hostname check + hostname = parsed.hostname + if not hostname: + raise ImageDownloadError("URL has no hostname.") + + # Resolve and check for private/loopback IPs (SSRF prevention) + try: + infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror as e: + raise ImageDownloadError(f"Cannot resolve hostname '{hostname}': {e}") from e + + for family, _, _, _, sockaddr in infos: + ip = ipaddress.ip_address(sockaddr[0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise ImageDownloadError( + f"Access to private/internal address {ip} is blocked." + ) + + return url + + +def safe_download_image(url: str) -> bytes: + """Download image data from *url* with security and size guards. + + Raises ``ImageDownloadError`` on validation failure or oversized response. + """ + url = _validate_url(url) + + req = Request(url, headers={"User-Agent": "PyBreeze-DiagramEditor/1.0"}) + resp = urlopen(req, timeout=TIMEOUT_SECONDS) # noqa: S310 — URL validated above + + # Check Content-Length header if available + content_length = resp.headers.get("Content-Length") + if content_length is not None and int(content_length) > MAX_DOWNLOAD_BYTES: + raise ImageDownloadError( + f"Image too large ({int(content_length)} bytes, max {MAX_DOWNLOAD_BYTES})." + ) + + # Read with size limit + data = resp.read(MAX_DOWNLOAD_BYTES + 1) + if len(data) > MAX_DOWNLOAD_BYTES: + raise ImageDownloadError( + f"Image exceeds {MAX_DOWNLOAD_BYTES // (1024 * 1024)} MB limit." + ) + + return data diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py new file mode 100644 index 0000000..508f7b8 --- /dev/null +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QColorDialog, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSpinBox, + QVBoxLayout, + QWidget, +) +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + ConnectionStyle, + DiagramConnection, + DiagramImage, + DiagramNode, + NodeShape, +) + +if TYPE_CHECKING: + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + +def _lang(key: str, fallback: str = "") -> str: + return language_wrapper.language_word_dict.get(key, fallback or key) + + +# --------------------------------------------------------------------------- +# Color picker button +# --------------------------------------------------------------------------- + +class ColorButton(QPushButton): + color_changed = Signal(QColor) + + def __init__(self, color: QColor = QColor("#e3f2fd"), parent=None): + super().__init__(parent) + self._color = color + self.setFixedHeight(24) + self.setMinimumWidth(60) + self._update_style() + self.clicked.connect(self._pick) + + def _update_style(self) -> None: + # Choose text color based on background brightness + luma = self._color.red() * 0.299 + self._color.green() * 0.587 + self._color.blue() * 0.114 + text_color = "#000000" if luma > 128 else "#ffffff" + self.setStyleSheet( + f"background-color: {self._color.name()}; color: {text_color};" + f" border: 1px solid #888; border-radius: 3px;" + ) + self.setText(self._color.name()) + + def _pick(self) -> None: + color = QColorDialog.getColor(self._color, self.window()) + if color.isValid(): + self._color = color + self._update_style() + self.color_changed.emit(color) + + def color(self) -> QColor: + return self._color + + def set_color(self, color: QColor) -> None: + self._color = color + self._update_style() + + +# --------------------------------------------------------------------------- +# Property panel +# --------------------------------------------------------------------------- + +_SHAPE_ITEMS: list[tuple[str, NodeShape]] = [ + ("diagram_editor_shape_rectangle", NodeShape.RECTANGLE), + ("diagram_editor_shape_rounded_rect", NodeShape.ROUNDED_RECT), + ("diagram_editor_shape_ellipse", NodeShape.ELLIPSE), + ("diagram_editor_shape_diamond", NodeShape.DIAMOND), +] + +_STYLE_ITEMS: list[tuple[str, ConnectionStyle]] = [ + ("diagram_editor_style_solid", ConnectionStyle.SOLID), + ("diagram_editor_style_dashed", ConnectionStyle.DASHED), + ("diagram_editor_style_dotted", ConnectionStyle.DOTTED), +] + + +class DiagramPropertyPanel(QWidget): + """Sidebar panel showing properties of the selected diagram item.""" + + def __init__(self, scene: DiagramScene, parent=None): + super().__init__(parent) + self._scene = scene + self._updating = False # guard against feedback loops + self._current_node: DiagramNode | None = None + self._current_conn: DiagramConnection | None = None + self._current_img: DiagramImage | None = None + self.setMinimumWidth(240) + self.setMaximumWidth(320) + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + container = QWidget() + self._layout = QVBoxLayout(container) + self._layout.setContentsMargins(8, 8, 8, 8) + self._layout.setSpacing(8) + self._layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # --- Title --- + self._title = QLabel(_lang("diagram_editor_prop_title", "Properties")) + self._title.setStyleSheet("font-weight: bold; font-size: 13px; padding: 4px 0px;") + self._layout.addWidget(self._title) + + # --- No selection label --- + self._no_sel_label = QLabel(_lang("diagram_editor_prop_no_selection", "No selection")) + self._no_sel_label.setContentsMargins(0, 8, 0, 0) + self._layout.addWidget(self._no_sel_label) + + # --- Node group --- + self._node_group = QGroupBox(_lang("diagram_editor_prop_node_group", "Node")) + nf = QFormLayout() + nf.setContentsMargins(8, 12, 8, 8) + nf.setVerticalSpacing(8) + nf.setHorizontalSpacing(10) + self._node_text = QLineEdit() + self._node_text.editingFinished.connect(self._on_node_text) + nf.addRow(_lang("diagram_editor_prop_text", "Text"), self._node_text) + + self._node_w = QSpinBox() + self._node_w.setRange(40, 800) + self._node_w.valueChanged.connect(self._on_node_size) + nf.addRow(_lang("diagram_editor_prop_width", "Width"), self._node_w) + + self._node_h = QSpinBox() + self._node_h.setRange(20, 600) + self._node_h.valueChanged.connect(self._on_node_size) + nf.addRow(_lang("diagram_editor_prop_height", "Height"), self._node_h) + + self._node_shape = QComboBox() + for lang_key, _ in _SHAPE_ITEMS: + self._node_shape.addItem(_lang(lang_key, lang_key)) + self._node_shape.currentIndexChanged.connect(self._on_node_shape) + nf.addRow(_lang("diagram_editor_prop_shape", "Shape"), self._node_shape) + + self._node_fill = ColorButton() + self._node_fill.color_changed.connect(self._on_node_fill) + nf.addRow(_lang("diagram_editor_prop_fill_color", "Fill"), self._node_fill) + + self._node_border = ColorButton(QColor("#455a64")) + self._node_border.color_changed.connect(self._on_node_border) + nf.addRow(_lang("diagram_editor_prop_border_color", "Border"), self._node_border) + + self._node_font = QSpinBox() + self._node_font.setRange(6, 48) + self._node_font.valueChanged.connect(self._on_node_font) + nf.addRow(_lang("diagram_editor_prop_font_size", "Font"), self._node_font) + + self._node_group.setLayout(nf) + self._layout.addWidget(self._node_group) + self._node_group.hide() + + # --- Connection group --- + self._conn_group = QGroupBox(_lang("diagram_editor_prop_conn_group", "Connection")) + cf = QFormLayout() + cf.setContentsMargins(8, 12, 8, 8) + cf.setVerticalSpacing(8) + cf.setHorizontalSpacing(10) + self._conn_label = QLineEdit() + self._conn_label.editingFinished.connect(self._on_conn_label) + cf.addRow(_lang("diagram_editor_prop_label", "Label"), self._conn_label) + + self._conn_style = QComboBox() + for lang_key, _ in _STYLE_ITEMS: + self._conn_style.addItem(_lang(lang_key, lang_key)) + self._conn_style.currentIndexChanged.connect(self._on_conn_style) + cf.addRow(_lang("diagram_editor_prop_line_style", "Style"), self._conn_style) + + self._conn_color = ColorButton(QColor("#37474f")) + self._conn_color.color_changed.connect(self._on_conn_color) + cf.addRow(_lang("diagram_editor_prop_line_color", "Color"), self._conn_color) + + self._conn_width = QDoubleSpinBox() + self._conn_width.setRange(0.5, 10.0) + self._conn_width.setSingleStep(0.5) + self._conn_width.valueChanged.connect(self._on_conn_width) + cf.addRow(_lang("diagram_editor_prop_line_width", "Width"), self._conn_width) + + self._conn_group.setLayout(cf) + self._layout.addWidget(self._conn_group) + self._conn_group.hide() + + # --- Image group --- + self._img_group = QGroupBox(_lang("diagram_editor_prop_img_group", "Image")) + imf = QFormLayout() + imf.setContentsMargins(8, 12, 8, 8) + imf.setVerticalSpacing(8) + imf.setHorizontalSpacing(10) + + self._img_caption = QLineEdit() + self._img_caption.editingFinished.connect(self._on_img_caption) + imf.addRow(_lang("diagram_editor_prop_caption", "Caption"), self._img_caption) + + self._img_source = QLineEdit() + self._img_source.setReadOnly(True) + imf.addRow(_lang("diagram_editor_prop_source", "Source"), self._img_source) + + self._img_w = QSpinBox() + self._img_w.setRange(40, 2000) + self._img_w.valueChanged.connect(self._on_img_size) + imf.addRow(_lang("diagram_editor_prop_width", "Width"), self._img_w) + + self._img_h = QSpinBox() + self._img_h.setRange(40, 2000) + self._img_h.valueChanged.connect(self._on_img_size) + imf.addRow(_lang("diagram_editor_prop_height", "Height"), self._img_h) + + self._img_group.setLayout(imf) + self._layout.addWidget(self._img_group) + self._img_group.hide() + + self._layout.addStretch() + scroll.setWidget(container) + outer.addWidget(scroll) + + # Connect to scene + self._scene.selectionChanged.connect(self._on_selection_changed) + + # ------------------------------------------------------------------ + # Selection handling + # ------------------------------------------------------------------ + + def _on_selection_changed(self) -> None: + self._updating = True + self._current_node = None + self._current_conn = None + self._current_img = None + selected = self._scene.selectedItems() + + nodes = [i for i in selected if isinstance(i, DiagramNode)] + conns = [i for i in selected if isinstance(i, DiagramConnection)] + imgs = [i for i in selected if isinstance(i, DiagramImage)] + + if len(nodes) == 1 and len(conns) == 0 and len(imgs) == 0: + self._show_node(nodes[0]) + elif len(conns) == 1 and len(nodes) == 0 and len(imgs) == 0: + self._show_connection(conns[0]) + elif len(imgs) == 1 and len(nodes) == 0 and len(conns) == 0: + self._show_image(imgs[0]) + else: + self._show_none() + self._updating = False + + def _show_none(self) -> None: + self._no_sel_label.show() + self._node_group.hide() + self._conn_group.hide() + self._img_group.hide() + + def _show_node(self, node: DiagramNode) -> None: + self._current_node = node + self._no_sel_label.hide() + self._node_group.show() + self._conn_group.hide() + self._img_group.hide() + + self._node_text.setText(node.text()) + self._node_w.setValue(int(node.node_w)) + self._node_h.setValue(int(node.node_h)) + # Find shape index + for idx, (_, shape) in enumerate(_SHAPE_ITEMS): + if shape == node.shape_type: + self._node_shape.setCurrentIndex(idx) + break + self._node_fill.set_color(node._fill_color) + self._node_border.set_color(node._border_color) + self._node_font.setValue(node._font_size) + + def _show_connection(self, conn: DiagramConnection) -> None: + self._current_conn = conn + self._no_sel_label.hide() + self._node_group.hide() + self._conn_group.show() + self._img_group.hide() + + self._conn_label.setText(conn.edge_label()) + for idx, (_, style) in enumerate(_STYLE_ITEMS): + if style == conn._style: + self._conn_style.setCurrentIndex(idx) + break + self._conn_color.set_color(conn._line_color) + self._conn_width.setValue(conn._line_width) + + # ------------------------------------------------------------------ + # Node property handlers + # ------------------------------------------------------------------ + + def _on_node_text(self) -> None: + if self._updating or self._current_node is None: + return + with self._scene.undo_scope("Edit Text"): + self._current_node.set_text(self._node_text.text()) + + def _on_node_size(self) -> None: + if self._updating or self._current_node is None: + return + with self._scene.undo_scope("Resize"): + self._current_node.set_size(self._node_w.value(), self._node_h.value()) + + def _on_node_shape(self, index: int) -> None: + if self._updating or self._current_node is None: + return + if 0 <= index < len(_SHAPE_ITEMS): + with self._scene.undo_scope("Change Shape"): + self._current_node.set_shape(_SHAPE_ITEMS[index][1]) + + def _on_node_fill(self, color: QColor) -> None: + if self._updating or self._current_node is None: + return + with self._scene.undo_scope("Change Fill"): + self._current_node.set_fill_color(color) + + def _on_node_border(self, color: QColor) -> None: + if self._updating or self._current_node is None: + return + with self._scene.undo_scope("Change Border"): + self._current_node.set_border_color(color) + + def _on_node_font(self, size: int) -> None: + if self._updating or self._current_node is None: + return + with self._scene.undo_scope("Change Font"): + self._current_node.set_font_size(size) + + # ------------------------------------------------------------------ + # Connection property handlers + # ------------------------------------------------------------------ + + def _on_conn_label(self) -> None: + if self._updating or self._current_conn is None: + return + with self._scene.undo_scope("Edit Label"): + self._current_conn.set_edge_label(self._conn_label.text()) + + def _on_conn_style(self, index: int) -> None: + if self._updating or self._current_conn is None: + return + if 0 <= index < len(_STYLE_ITEMS): + with self._scene.undo_scope("Change Style"): + self._current_conn.set_style(_STYLE_ITEMS[index][1]) + + def _on_conn_color(self, color: QColor) -> None: + if self._updating or self._current_conn is None: + return + with self._scene.undo_scope("Change Color"): + self._current_conn.set_line_color(color) + + def _on_conn_width(self, width: float) -> None: + if self._updating or self._current_conn is None: + return + with self._scene.undo_scope("Change Width"): + self._current_conn.set_line_width(width) + + # ------------------------------------------------------------------ + # Image property handlers + # ------------------------------------------------------------------ + + def _show_image(self, img: DiagramImage) -> None: + self._current_img = img + self._no_sel_label.hide() + self._node_group.hide() + self._conn_group.hide() + self._img_group.show() + + self._img_caption.setText(img.text()) + src = img.source() + self._img_source.setText(src if src else "") + self._img_w.setValue(int(img.img_w)) + self._img_h.setValue(int(img.img_h)) + + def _on_img_caption(self) -> None: + if self._updating or self._current_img is None: + return + with self._scene.undo_scope("Edit Caption"): + self._current_img.set_text(self._img_caption.text()) + + def _on_img_size(self) -> None: + if self._updating or self._current_img is None: + return + with self._scene.undo_scope("Resize Image"): + self._current_img.set_size(self._img_w.value(), self._img_h.value()) diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py new file mode 100644 index 0000000..624c7c3 --- /dev/null +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -0,0 +1,605 @@ +from __future__ import annotations + +from contextlib import contextmanager +from enum import Enum, auto + +from PySide6.QtCore import QPointF, Qt, Signal +from PySide6.QtGui import QColor, QPen, QPixmap, QUndoStack +from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsScene, QMenu + +from pybreeze.pybreeze_ui.diagram_editor.diagram_commands import DiagramSnapshotCommand +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + ConnectionStyle, + DiagramConnection, + DiagramImage, + DiagramNode, + NodeShape, + ResizeHandle, +) + + +class ToolMode(Enum): + """State pattern: each mode defines how mouse events behave on the canvas.""" + SELECT = auto() + ADD_RECT = auto() + ADD_ROUNDED_RECT = auto() + ADD_ELLIPSE = auto() + ADD_DIAMOND = auto() + ADD_CONNECTION = auto() + ADD_TEXT = auto() + + +_MODE_SHAPE_MAP: dict[ToolMode, NodeShape] = { + ToolMode.ADD_RECT: NodeShape.RECTANGLE, + ToolMode.ADD_ROUNDED_RECT: NodeShape.ROUNDED_RECT, + ToolMode.ADD_ELLIPSE: NodeShape.ELLIPSE, + ToolMode.ADD_DIAMOND: NodeShape.DIAMOND, +} + + +class DiagramScene(QGraphicsScene): + """QGraphicsScene with tool-mode state, undo/redo, grid, copy/paste, and align.""" + + mode_changed = Signal(ToolMode) + item_count_changed = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._mode: ToolMode = ToolMode.SELECT + self._connection_source: DiagramNode | None = None + self._temp_line: QGraphicsLineItem | None = None + self.setSceneRect(-4000, -4000, 8000, 8000) + + # BSP tree for fast spatial queries on large diagrams + self.setItemIndexMethod(QGraphicsScene.ItemIndexMethod.BspTreeIndex) + self.setBspTreeDepth(14) + + # Undo/Redo + self.undo_stack = QUndoStack(self) + self._pending_undo_snapshot: dict | None = None + self._pending_undo_desc: str | None = None + + # Grid + self._grid_enabled = False + self._grid_size = 20 + + # Clipboard + self._clipboard: dict | None = None + + # ------------------------------------------------------------------ + # Grid + # ------------------------------------------------------------------ + + @property + def grid_enabled(self) -> bool: + return self._grid_enabled + + @grid_enabled.setter + def grid_enabled(self, value: bool) -> None: + self._grid_enabled = value + DiagramNode.grid_enabled = value + + @property + def grid_size(self) -> int: + return self._grid_size + + @grid_size.setter + def grid_size(self, value: int) -> None: + self._grid_size = max(5, value) + DiagramNode.grid_size = self._grid_size + + # ------------------------------------------------------------------ + # State management + # ------------------------------------------------------------------ + + @property + def mode(self) -> ToolMode: + return self._mode + + @mode.setter + def mode(self, value: ToolMode) -> None: + self._cancel_connection() + focus = self.focusItem() + if focus is not None: + focus.clearFocus() + self._mode = value + self.mode_changed.emit(value) + + # ------------------------------------------------------------------ + # Undo helpers + # ------------------------------------------------------------------ + + def _snapshot(self) -> dict: + return self.to_dict() + + def begin_undo(self, description: str) -> None: + self._pending_undo_desc = description + self._pending_undo_snapshot = self._snapshot() + + def end_undo(self) -> None: + if self._pending_undo_snapshot is None: + return + new = self._snapshot() + if new != self._pending_undo_snapshot: + cmd = DiagramSnapshotCommand( + self, self._pending_undo_desc or "Edit", + self._pending_undo_snapshot, new, + ) + self.undo_stack.push(cmd) + self._pending_undo_snapshot = None + self._pending_undo_desc = None + + @contextmanager + def undo_scope(self, description: str): + self.begin_undo(description) + yield + self.end_undo() + + def _restore_from_dict(self, data: dict) -> None: + """Rebuild scene from serialised data (used by undo/redo).""" + self.blockSignals(True) + self._clear_items() + self._load_items(data) + self.blockSignals(False) + self.item_count_changed.emit() + # Trigger selection change so property panel updates + self.selectionChanged.emit() + + # ------------------------------------------------------------------ + # Mouse handlers + # ------------------------------------------------------------------ + + def mousePressEvent(self, event) -> None: + if event.button() != Qt.MouseButton.LeftButton: + super().mousePressEvent(event) + return + + pos = event.scenePos() + + # Node creation modes + shape = _MODE_SHAPE_MAP.get(self._mode) + if shape is not None: + with self.undo_scope("Add Node"): + node = DiagramNode(x=pos.x() - 70, y=pos.y() - 30, shape=shape) + self.addItem(node) + self.item_count_changed.emit() + self.mode = ToolMode.SELECT + return + + # Text-only node + if self._mode == ToolMode.ADD_TEXT: + with self.undo_scope("Add Text"): + node = DiagramNode( + x=pos.x() - 70, y=pos.y() - 20, + w=140, h=40, text="Text", + shape=NodeShape.RECTANGLE, + ) + self.addItem(node) + self.item_count_changed.emit() + self.mode = ToolMode.SELECT + return + + # Connection mode + if self._mode == ToolMode.ADD_CONNECTION: + target_node = self._node_at(pos) + if target_node is not None: + if self._connection_source is None: + # First click — pick source + self._connection_source = target_node + self._temp_line = QGraphicsLineItem() + self._temp_line.setPen(QPen(QColor("#90a4ae"), 1.5, Qt.PenStyle.DashLine)) + self._temp_line.setZValue(-100) + self._temp_line.setAcceptedMouseButtons(Qt.MouseButton.NoButton) + center = target_node.center_pos() + self._temp_line.setLine(center.x(), center.y(), pos.x(), pos.y()) + self.addItem(self._temp_line) + else: + # Second click — create connection + if target_node is not self._connection_source: + with self.undo_scope("Add Connection"): + conn = DiagramConnection(self._connection_source, target_node) + self.addItem(conn) + self.item_count_changed.emit() + self._cancel_connection() + self.mode = ToolMode.SELECT + return + # Clicked empty area — cancel current connection attempt + if self._connection_source is not None: + self._cancel_connection() + return + return + + # SELECT mode — track for move undo + if self._mode == ToolMode.SELECT: + super().mousePressEvent(event) + selected_nodes = [i for i in self.selectedItems() if isinstance(i, DiagramNode)] + if selected_nodes: + self.begin_undo("Move") + return + + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: + if self._temp_line is not None and self._connection_source is not None: + center = self._connection_source.center_pos() + self._temp_line.setLine( + center.x(), center.y(), + event.scenePos().x(), event.scenePos().y(), + ) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: + super().mouseReleaseEvent(event) + self.end_undo() + + def keyPressEvent(self, event) -> None: + if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): + focus = self.focusItem() + if focus is not None and focus.textInteractionFlags() & Qt.TextInteractionFlag.TextEditorInteraction: + super().keyPressEvent(event) + return + self.delete_selected() + return + if event.key() == Qt.Key.Key_Escape: + self._cancel_connection() + self.mode = ToolMode.SELECT + return + super().keyPressEvent(event) + + def contextMenuEvent(self, event) -> None: + from je_editor import language_wrapper + menu = QMenu() + item = self._node_at(event.scenePos()) + conn = self._connection_at(event.scenePos()) + + if item is not None or conn is not None: + menu.addAction( + language_wrapper.language_word_dict.get("diagram_editor_ctx_delete", "Delete"), + self.delete_selected, + ) + if item is not None: + menu.addAction( + language_wrapper.language_word_dict.get("diagram_editor_ctx_duplicate", "Duplicate"), + self.duplicate_selected, + ) + menu.addSeparator() + menu.addAction( + language_wrapper.language_word_dict.get("diagram_editor_ctx_bring_front", "Bring to Front"), + lambda: self._change_z(1), + ) + menu.addAction( + language_wrapper.language_word_dict.get("diagram_editor_ctx_send_back", "Send to Back"), + lambda: self._change_z(-1), + ) + else: + if self._clipboard: + menu.addAction( + language_wrapper.language_word_dict.get("diagram_editor_ctx_paste", "Paste"), + self.paste_clipboard, + ) + menu.addAction( + language_wrapper.language_word_dict.get("diagram_editor_ctx_select_all", "Select All"), + self.select_all, + ) + + if menu.actions(): + menu.exec(event.screenPos()) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _node_at(self, pos: QPointF) -> DiagramNode | None: + """Find the topmost DiagramNode at pos, skipping temp_line and resize handles.""" + for item in self.items(pos): + if item is self._temp_line: + continue + if isinstance(item, ResizeHandle): + continue + node = self._find_parent_node(item) + if node is not None: + return node + return None + + def _connection_at(self, pos: QPointF) -> DiagramConnection | None: + for item in self.items(pos): + if isinstance(item, DiagramConnection): + return item + return None + + @staticmethod + def _find_parent_node(item) -> DiagramNode | None: + while item is not None: + if isinstance(item, DiagramNode): + return item + item = item.parentItem() + return None + + def _cancel_connection(self) -> None: + self._connection_source = None + if self._temp_line is not None: + self.removeItem(self._temp_line) + self._temp_line = None + + def _change_z(self, direction: int) -> None: + for item in self.selectedItems(): + if isinstance(item, DiagramNode): + item.setZValue(item.zValue() + direction) + + # ------------------------------------------------------------------ + # Operations (all undoable) + # ------------------------------------------------------------------ + + def delete_selected(self) -> None: + selected = self.selectedItems() + if not selected: + return + with self.undo_scope("Delete"): + for item in list(selected): + if isinstance(item, DiagramConnection): + item.detach() + self.removeItem(item) + for item in list(selected): + if isinstance(item, DiagramNode): + for conn in list(item.connections): + conn.detach() + self.removeItem(conn) + self.removeItem(item) + elif isinstance(item, DiagramImage): + self.removeItem(item) + self.item_count_changed.emit() + + def select_all(self) -> None: + for item in self.items(): + if isinstance(item, (DiagramNode, DiagramConnection, DiagramImage)): + item.setSelected(True) + + def copy_selected(self) -> None: + nodes = [i for i in self.selectedItems() if isinstance(i, DiagramNode)] + if not nodes: + return + node_set = set(nodes) + connections = [ + i for i in self.selectedItems() + if isinstance(i, DiagramConnection) + and i.source in node_set and i.target in node_set + ] + node_map = {n: idx for idx, n in enumerate(nodes)} + self._clipboard = { + "nodes": [n.to_dict(node_map[n]) for n in nodes], + "connections": [c.to_dict(node_map) for c in connections], + } + + def paste_clipboard(self) -> None: + if not self._clipboard: + return + with self.undo_scope("Paste"): + self.clearSelection() + id_to_node: dict[int, DiagramNode] = {} + for nd in self._clipboard["nodes"]: + data = dict(nd) + data["x"] += 30 + data["y"] += 30 + node = DiagramNode.from_dict(data) + self.addItem(node) + node.setSelected(True) + id_to_node[nd["id"]] = node + for cd in self._clipboard["connections"]: + src = id_to_node.get(cd["source"]) + tgt = id_to_node.get(cd["target"]) + if src is not None and tgt is not None: + conn = DiagramConnection( + src, tgt, + label=cd.get("label", ""), + line_color=cd.get("line_color"), + line_width=cd.get("line_width", 2.0), + style=ConnectionStyle[cd.get("style", "SOLID")], + ) + self.addItem(conn) + self.item_count_changed.emit() + + def duplicate_selected(self) -> None: + self.copy_selected() + self.paste_clipboard() + + # ------------------------------------------------------------------ + # Align tools (all undoable) + # ------------------------------------------------------------------ + + def _selected_nodes(self) -> list[DiagramNode]: + return [i for i in self.selectedItems() if isinstance(i, DiagramNode)] + + def align_left(self) -> None: + nodes = self._selected_nodes() + if len(nodes) < 2: + return + target = min(n.pos().x() for n in nodes) + with self.undo_scope("Align Left"): + for n in nodes: + n.setPos(target, n.pos().y()) + + def align_right(self) -> None: + nodes = self._selected_nodes() + if len(nodes) < 2: + return + target = max(n.pos().x() + n.node_w for n in nodes) + with self.undo_scope("Align Right"): + for n in nodes: + n.setPos(target - n.node_w, n.pos().y()) + + def align_top(self) -> None: + nodes = self._selected_nodes() + if len(nodes) < 2: + return + target = min(n.pos().y() for n in nodes) + with self.undo_scope("Align Top"): + for n in nodes: + n.setPos(n.pos().x(), target) + + def align_bottom(self) -> None: + nodes = self._selected_nodes() + if len(nodes) < 2: + return + target = max(n.pos().y() + n.node_h for n in nodes) + with self.undo_scope("Align Bottom"): + for n in nodes: + n.setPos(n.pos().x(), target - n.node_h) + + def align_center_h(self) -> None: + nodes = self._selected_nodes() + if len(nodes) < 2: + return + centers = [n.pos().x() + n.node_w / 2 for n in nodes] + target = sum(centers) / len(centers) + with self.undo_scope("Center Horizontal"): + for n in nodes: + n.setPos(target - n.node_w / 2, n.pos().y()) + + def align_center_v(self) -> None: + nodes = self._selected_nodes() + if len(nodes) < 2: + return + centers = [n.pos().y() + n.node_h / 2 for n in nodes] + target = sum(centers) / len(centers) + with self.undo_scope("Center Vertical"): + for n in nodes: + n.setPos(n.pos().x(), target - n.node_h / 2) + + def distribute_h(self) -> None: + nodes = self._selected_nodes() + if len(nodes) < 3: + return + nodes.sort(key=lambda n: n.pos().x()) + left = nodes[0].pos().x() + right = nodes[-1].pos().x() + nodes[-1].node_w + total_w = sum(n.node_w for n in nodes) + gap = (right - left - total_w) / (len(nodes) - 1) if len(nodes) > 1 else 0 + with self.undo_scope("Distribute Horizontal"): + x = left + for n in nodes: + n.setPos(x, n.pos().y()) + x += n.node_w + gap + + def distribute_v(self) -> None: + nodes = self._selected_nodes() + if len(nodes) < 3: + return + nodes.sort(key=lambda n: n.pos().y()) + top = nodes[0].pos().y() + bottom = nodes[-1].pos().y() + nodes[-1].node_h + total_h = sum(n.node_h for n in nodes) + gap = (bottom - top - total_h) / (len(nodes) - 1) if len(nodes) > 1 else 0 + with self.undo_scope("Distribute Vertical"): + y = top + for n in nodes: + n.setPos(n.pos().x(), y) + y += n.node_h + gap + + # ------------------------------------------------------------------ + # Image operations + # ------------------------------------------------------------------ + + def add_image(self, pixmap: QPixmap, source: str, pos: QPointF | None = None) -> DiagramImage: + """Add an image to the scene at *pos* (default: centre of view).""" + w = min(pixmap.width(), 400) + h = int(pixmap.height() * w / max(pixmap.width(), 1)) + if pos is None: + views = self.views() + if views: + pos = views[0].mapToScene(views[0].viewport().rect().center()) + else: + pos = QPointF(0, 0) + with self.undo_scope("Add Image"): + img = DiagramImage(x=pos.x() - w / 2, y=pos.y() - h / 2, w=w, h=h, source=source, pixmap=pixmap) + self.addItem(img) + self.item_count_changed.emit() + return img + + def get_all_images(self) -> list[DiagramImage]: + return [item for item in self.items() if isinstance(item, DiagramImage)] + + # ------------------------------------------------------------------ + # Serialisation + # ------------------------------------------------------------------ + + def get_all_nodes(self) -> list[DiagramNode]: + return [item for item in self.items() if isinstance(item, DiagramNode)] + + def get_all_connections(self) -> list[DiagramConnection]: + return [item for item in self.items() if isinstance(item, DiagramConnection)] + + def to_dict(self) -> dict: + nodes = self.get_all_nodes() + node_map: dict[DiagramNode, int] = {n: i for i, n in enumerate(nodes)} + return { + "nodes": [n.to_dict(node_map[n]) for n in nodes], + "connections": [c.to_dict(node_map) for c in self.get_all_connections()], + "images": [img.to_dict(i) for i, img in enumerate(self.get_all_images())], + } + + def _clear_items(self) -> None: + """Remove all diagram items without clearing the scene entirely.""" + for item in list(self.items()): + if isinstance(item, (DiagramNode, DiagramConnection, DiagramImage)): + if isinstance(item, DiagramConnection): + item.detach() + self.removeItem(item) + + def _load_items(self, data: dict) -> None: + """Add items from serialised data.""" + id_to_node: dict[int, DiagramNode] = {} + for nd in data.get("nodes", []): + node = DiagramNode.from_dict(nd) + self.addItem(node) + id_to_node[nd["id"]] = node + for cd in data.get("connections", []): + src = id_to_node.get(cd["source"]) + tgt = id_to_node.get(cd["target"]) + if src is not None and tgt is not None: + conn = DiagramConnection( + src, tgt, + label=cd.get("label", ""), + line_color=cd.get("line_color"), + line_width=cd.get("line_width", 2.0), + style=ConnectionStyle[cd.get("style", "SOLID")], + ) + self.addItem(conn) + for img_d in data.get("images", []): + img = DiagramImage.from_dict(img_d) + self.addItem(img) + # Try to reload pixmap from source + source = img_d.get("source", "") + if source: + self._try_load_image_source(img, source) + + def _try_load_image_source(self, img: DiagramImage, source: str) -> None: + """Load pixmap from local path or URL into a DiagramImage. + + Local paths are restricted to existing image files. + URLs are validated and size-limited via ``safe_download_image``. + """ + from pathlib import Path + path = Path(source) + # Only load if the file actually exists and has an image extension + _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".svg", ".webp", ".ico"} + if path.is_file() and path.suffix.lower() in _IMAGE_SUFFIXES: + pix = QPixmap(str(path)) + if not pix.isNull(): + img.set_pixmap(pix, source) + return + if source.startswith(("http://", "https://")): + try: + from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import safe_download_image + data = safe_download_image(source) + pix = QPixmap() + pix.loadFromData(data) + if not pix.isNull(): + img.set_pixmap(pix, source) + except Exception: + pass + + def load_from_dict(self, data: dict) -> None: + self._clear_items() + self._load_items(data) + self.undo_stack.clear() + self.item_count_changed.emit() diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py new file mode 100644 index 0000000..9d997fb --- /dev/null +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from PySide6.QtCore import QRectF, Qt, Signal +from PySide6.QtGui import QBrush, QColor, QPainter, QPen +from PySide6.QtWidgets import QGraphicsView + +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + +_ZOOM_FACTOR = 1.15 +_MIN_SCALE = 0.1 +_MAX_SCALE = 5.0 +_GRID_COLOR = QColor("#e0e0e0") +_GRID_COLOR_MAJOR = QColor("#bdbdbd") +_BG_COLOR = QColor("#ffffff") + + +class DiagramView(QGraphicsView): + """Pannable, zoomable view with performance optimisations. + + - MinimalViewportUpdate: only repaint dirty regions + - CacheBackground: grid painted once, blitted on scroll + - DontAdjustForAntialiasing: skip bounding-rect inflation + - Item-level DeviceCoordinateCache (set on each item) + - BSP tree index on the scene for O(log n) spatial queries + """ + + zoom_changed = Signal(int) # percentage + + def __init__(self, scene: DiagramScene, parent=None): + super().__init__(scene, parent) + + # --- Background --- + self.setBackgroundBrush(QBrush(_BG_COLOR)) + + # --- Render hints --- + self.setRenderHints( + QPainter.RenderHint.Antialiasing + | QPainter.RenderHint.SmoothPixmapTransform + | QPainter.RenderHint.TextAntialiasing + ) + + # --- Performance --- + self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.MinimalViewportUpdate) + self.setOptimizationFlags( + QGraphicsView.OptimizationFlag.DontAdjustForAntialiasing + ) + self.setCacheMode(QGraphicsView.CacheModeFlag.CacheBackground) + + # --- Interaction --- + self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) + self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self._panning = False + self._pan_start = None + self._draw_grid = False + self._grid_size = 20 + + # --- grid --- + + @property + def draw_grid(self) -> bool: + return self._draw_grid + + @draw_grid.setter + def draw_grid(self, value: bool) -> None: + self._draw_grid = value + self.resetCachedContent() + self.viewport().update() + + @property + def grid_size(self) -> int: + return self._grid_size + + @grid_size.setter + def grid_size(self, value: int) -> None: + self._grid_size = max(5, value) + if self._draw_grid: + self.resetCachedContent() + self.viewport().update() + + def drawBackground(self, painter: QPainter, rect: QRectF) -> None: + # Fill background explicitly (required for OpenGL viewport) + painter.fillRect(rect, _BG_COLOR) + + if not self._draw_grid: + return + + gs = self._grid_size + left = int(rect.left()) - (int(rect.left()) % gs) + top = int(rect.top()) - (int(rect.top()) % gs) + + # Minor grid + painter.setPen(QPen(_GRID_COLOR, 0.5)) + x = left + while x <= rect.right(): + painter.drawLine(int(x), int(rect.top()), int(x), int(rect.bottom())) + x += gs + y = top + while y <= rect.bottom(): + painter.drawLine(int(rect.left()), int(y), int(rect.right()), int(y)) + y += gs + + # Major grid (every 5 cells) + major = gs * 5 + painter.setPen(QPen(_GRID_COLOR_MAJOR, 1)) + left_major = int(rect.left()) - (int(rect.left()) % major) + top_major = int(rect.top()) - (int(rect.top()) % major) + x = left_major + while x <= rect.right(): + painter.drawLine(int(x), int(rect.top()), int(x), int(rect.bottom())) + x += major + y = top_major + while y <= rect.bottom(): + painter.drawLine(int(rect.left()), int(y), int(rect.right()), int(y)) + y += major + + # --- zoom --- + + def wheelEvent(self, event) -> None: + factor = _ZOOM_FACTOR if event.angleDelta().y() > 0 else 1.0 / _ZOOM_FACTOR + current = self.transform().m11() + if current * factor < _MIN_SCALE or current * factor > _MAX_SCALE: + return + self.scale(factor, factor) + self.zoom_changed.emit(int(self.transform().m11() * 100)) + + def set_zoom(self, percent: int) -> None: + factor = percent / 100.0 + factor = max(_MIN_SCALE, min(factor, _MAX_SCALE)) + self.resetTransform() + self.scale(factor, factor) + self.zoom_changed.emit(int(factor * 100)) + + def zoom_in(self) -> None: + current = self.transform().m11() + if current * _ZOOM_FACTOR <= _MAX_SCALE: + self.scale(_ZOOM_FACTOR, _ZOOM_FACTOR) + self.zoom_changed.emit(int(self.transform().m11() * 100)) + + def zoom_out(self) -> None: + current = self.transform().m11() + if current / _ZOOM_FACTOR >= _MIN_SCALE: + self.scale(1.0 / _ZOOM_FACTOR, 1.0 / _ZOOM_FACTOR) + self.zoom_changed.emit(int(self.transform().m11() * 100)) + + # --- middle-button / right-button pan --- + + def mousePressEvent(self, event) -> None: + if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): + self._panning = True + self._pan_start = event.position().toPoint() + self.setCursor(Qt.CursorShape.ClosedHandCursor) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: + if self._panning and self._pan_start is not None: + delta = event.position().toPoint() - self._pan_start + self._pan_start = event.position().toPoint() + self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x()) + self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y()) + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: + if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton) and self._panning: + self._panning = False + self.setCursor(Qt.CursorShape.ArrowCursor) + event.accept() + return + super().mouseReleaseEvent(event) diff --git a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py index 2e0f29a..6fbe057 100644 --- a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py +++ b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py @@ -9,6 +9,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import AICodeReviewClient +from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import CoTPromptEditor from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.skills_prompt_editor_widget import \ SkillPromptEditor @@ -78,6 +79,17 @@ def build_tools_menu(ui_we_want_to_set: PyBreezeMainWindow): )) ui_we_want_to_set.tools_ai_menu.addAction(ui_we_want_to_set.tools_ai_skill_send_action) + # Diagram Editor + ui_we_want_to_set.tools_diagram_editor_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_diagram_editor_tab_action" + )) + ui_we_want_to_set.tools_diagram_editor_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + DiagramEditorWidget(), language_wrapper.language_word_dict.get( + "extend_tools_menu_diagram_editor_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_diagram_editor_action) + def extend_dock_menu(ui_we_want_to_set: PyBreezeMainWindow): # Sub menu @@ -121,6 +133,13 @@ def extend_dock_menu(ui_we_want_to_set: PyBreezeMainWindow): lambda: add_dock(ui_we_want_to_set, "SkillSendGUI")) ui_we_want_to_set.dock_ai_menu.addAction(ui_we_want_to_set.tools_skill_send_dock_action) + # Diagram Editor Dock + ui_we_want_to_set.tools_diagram_editor_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_diagram_editor_dock_action")) + ui_we_want_to_set.tools_diagram_editor_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "DiagramEditor")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_diagram_editor_dock_action) + def add_dock(ui_we_want_to_set: PyBreezeMainWindow, widget_type: str | None = None): jeditor_logger.info("build_dock_menu.py add_dock_widget " f"ui_we_want_to_set: {ui_we_want_to_set} " @@ -155,6 +174,11 @@ def add_dock(ui_we_want_to_set: PyBreezeMainWindow, widget_type: str | None = No "extend_tools_menu_skill_prompt_send_dock_title" )) dock_widget.setWidget(SkillsSendGUI()) + elif widget_type == "DiagramEditor": + dock_widget.setWindowTitle(language_wrapper.language_word_dict.get( + "extend_tools_menu_diagram_editor_dock_title" + )) + dock_widget.setWidget(DiagramEditorWidget()) # 如果成功建立了 widget,將其加到主視窗右側 Dock 區域 # If widget is created, add it to the right dock area of the main window