diff --git a/tools/graphviz_render.py b/tools/graphviz_render.py deleted file mode 100755 index 9d266285504c4bc356296538c06e15b3aecd7808..0000000000000000000000000000000000000000 --- a/tools/graphviz_render.py +++ /dev/null @@ -1,383 +0,0 @@ -#!/usr/bin/env python3 - -# -# .============. -# // M A K E / \ -# // C++ DEV / \ -# // E A S Y / \/ \ -# ++ ----------. \/\ . -# \\ \ \ /\ / -# \\ \ \ / -# \\ \ \ / -# -============' -# -# Copyright (c) 2025 Hevake and contributors, all rights reserved. -# -# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) -# Use of this source code is governed by MIT license that can be found -# in the LICENSE file in the root of the source tree. All contributing -# project authors may be found in the CONTRIBUTORS.md file in the root -# of the source tree. -# - -import os -import sys -import asyncio -import subprocess -from datetime import datetime -from io import BytesIO -from PIL import Image -from PyQt6.QtWidgets import (QApplication, QMainWindow, QLabel, QVBoxLayout, - QWidget, QScrollArea, QFrame) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread, QSize, QPoint -from PyQt6.QtGui import QImage, QPixmap, QFont, QPainter, QMouseEvent, QWheelEvent - -class ZoomableLabel(QLabel): - def __init__(self, parent=None): - super().__init__(parent) - self.zoom_factor = 1.0 - self.min_zoom = 0.1 - self.max_zoom = 10.0 - self.zoom_step = 1.2 - self.panning = False - self.last_pos = None - self.offset = QPoint(0, 0) - self.current_pixmap = None - self.setMouseTracking(True) - self.initial_fit = True - self.last_click_time = 0 - self.last_click_pos = None - - def wheelEvent(self, event: QWheelEvent): - if self.current_pixmap is None: - return - - # Get cursor position relative to the widget - cursor_pos = event.position() - - # Update zoom factor - if event.angleDelta().y() > 0: - self.zoom_at_position(cursor_pos.toPoint(), self.zoom_step) - else: - self.zoom_at_position(cursor_pos.toPoint(), 1.0 / self.zoom_step) - - def mousePressEvent(self, event: QMouseEvent): - if event.button() == Qt.MouseButton.RightButton: - self.panning = True - self.last_pos = event.position().toPoint() - self.setCursor(Qt.CursorShape.ClosedHandCursor) - elif event.button() == Qt.MouseButton.MiddleButton: - # Reset zoom and position when middle mouse button is pressed - self.fit_to_window() - elif event.button() == Qt.MouseButton.LeftButton: - current_time = event.timestamp() - current_pos = event.position().toPoint() - - # Check if this is a double click (within 500ms and same position) - if (current_time - self.last_click_time < 500 and - self.last_click_pos is not None and - (current_pos - self.last_click_pos).manhattanLength() < 5): - # Double click detected - zoom in at cursor position - self.zoom_at_position(current_pos, self.zoom_step) - self.last_click_time = 0 # Reset click time - self.last_click_pos = None # Reset click position - else: - # Single click - start panning - self.panning = True - self.last_pos = current_pos - self.setCursor(Qt.CursorShape.ClosedHandCursor) - # Update last click info for potential double click - self.last_click_time = current_time - self.last_click_pos = current_pos - - def mouseReleaseEvent(self, event: QMouseEvent): - if event.button() == Qt.MouseButton.RightButton or event.button() == Qt.MouseButton.LeftButton: - self.panning = False - self.setCursor(Qt.CursorShape.ArrowCursor) - - def mouseMoveEvent(self, event: QMouseEvent): - if self.panning and self.last_pos is not None: - delta = event.position().toPoint() - self.last_pos - self.offset += delta - self.last_pos = event.position().toPoint() - self.update_image(self.current_pixmap) - - def update_image(self, pixmap: QPixmap, zoom_center: QPoint = None): - if pixmap is None: - return - - self.current_pixmap = pixmap - - # If this is the first image, fit it to the window - if self.initial_fit: - self.initial_fit = False - self.fit_to_window() - return - - # Calculate new size based on zoom factor - new_width = int(pixmap.width() * self.zoom_factor) - new_height = int(pixmap.height() * self.zoom_factor) - - # Scale the pixmap - scaled_pixmap = pixmap.scaled( - new_width, - new_height, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation - ) - - # Create a new pixmap with the same size as the label - result_pixmap = QPixmap(self.size()) - result_pixmap.fill(Qt.GlobalColor.transparent) - - # Create painter for the result pixmap - painter = QPainter(result_pixmap) - - # Calculate the position to draw the scaled image - x = (self.width() - scaled_pixmap.width()) // 2 + self.offset.x() - y = (self.height() - scaled_pixmap.height()) // 2 + self.offset.y() - - # Draw the scaled image - painter.drawPixmap(x, y, scaled_pixmap) - painter.end() - - # Update the display - super().setPixmap(result_pixmap) - - # Update minimum size - self.setMinimumSize(new_width, new_height) - - def fit_to_window(self): - if self.current_pixmap is None: - return - - # Calculate scaling ratio to fit the window - width_ratio = self.width() / self.current_pixmap.width() - height_ratio = self.height() / self.current_pixmap.height() - self.zoom_factor = min(width_ratio, height_ratio) - - # Reset offset - self.offset = QPoint(0, 0) - - # Update display - self.update_image(self.current_pixmap) - - def resizeEvent(self, event): - super().resizeEvent(event) - # Always fit to window on resize - if self.current_pixmap is not None: - self.fit_to_window() - - def zoom_at_position(self, pos: QPoint, factor: float): - if self.current_pixmap is None: - return - - # Calculate cursor position relative to the image - image_x = pos.x() - (self.width() - self.current_pixmap.width() * self.zoom_factor) / 2 - self.offset.x() - image_y = pos.y() - (self.height() - self.current_pixmap.height() * self.zoom_factor) / 2 - self.offset.y() - - # Calculate the ratio of cursor position to image size - ratio_x = image_x / (self.current_pixmap.width() * self.zoom_factor) - ratio_y = image_y / (self.current_pixmap.height() * self.zoom_factor) - - # Update zoom factor - old_zoom = self.zoom_factor - new_zoom = min(self.zoom_factor * factor, self.max_zoom) - - # Calculate new image size - new_width = int(self.current_pixmap.width() * new_zoom) - new_height = int(self.current_pixmap.height() * new_zoom) - - # Calculate new cursor position relative to the image - new_image_x = ratio_x * new_width - new_image_y = ratio_y * new_height - - # Calculate new offset to keep cursor position fixed - new_x = pos.x() - (self.width() - new_width) / 2 - new_image_x - new_y = pos.y() - (self.height() - new_height) / 2 - new_image_y - - # Update zoom and offset - self.zoom_factor = new_zoom - self.offset = QPoint(int(new_x), int(new_y)) - - # Update display - self.update_image(self.current_pixmap) - -class PipeReader(QThread): - data_received = pyqtSignal(str) - - def __init__(self, pipe_name): - super().__init__() - self.pipe_name = pipe_name - self.running = True - - def run(self): - while self.running: - try: - with open(self.pipe_name, 'r') as pipe: - while self.running: - data = pipe.read() - if data: - self.data_received.emit(data) - # Small sleep to prevent CPU overuse - self.msleep(10) - except Exception as e: - print(f"Error reading from pipe: {e}") - self.msleep(1000) # Wait before retrying - - def stop(self): - self.running = False - -class GraphvizViewer(QMainWindow): - def __init__(self, pipe_name): - super().__init__() - self.pipe_name = pipe_name - self.original_image = None - self.current_pixmap = None - self.last_dot_data = '' - self.setup_ui() - - def setup_ui(self): - self.setWindowTitle(f"Graphviz: {self.pipe_name}") - self.setMinimumSize(300, 200) - - # Create central widget and layout - central_widget = QWidget() - self.setCentralWidget(central_widget) - layout = QVBoxLayout(central_widget) - layout.setContentsMargins(0, 0, 0, 0) # Remove margins - layout.setSpacing(0) # Remove spacing - - # Create timestamp label with minimal height - self.timestamp_label = QLabel() - self.timestamp_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.timestamp_label.setStyleSheet("background-color: #f0f0f0; padding: 2px;") - self.timestamp_label.setFont(QFont("Arial", 8)) # Smaller font - self.timestamp_label.setFixedHeight(20) # Fixed height for timestamp - - # Create zoomable image label - self.image_label = ZoomableLabel() - self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.image_label.setMinimumSize(1, 1) # Allow shrinking - - # Add widgets to main layout - layout.addWidget(self.timestamp_label) - layout.addWidget(self.image_label) - - # Setup pipe reader - self.pipe_reader = PipeReader(self.pipe_name) - self.pipe_reader.data_received.connect(self.update_graph) - self.pipe_reader.start() - - # Setup resize timer for debouncing - self.resize_timer = QTimer() - self.resize_timer.setSingleShot(True) - self.resize_timer.timeout.connect(self.update_image) - - # Connect resize event - self.resizeEvent = self.on_resize - - def on_resize(self, event): - super().resizeEvent(event) - # Debounce resize events - self.resize_timer.start(100) - - def update_graph(self, dot_data): - if dot_data != self.last_dot_data: - self.last_dot_data = dot_data - - try: - # Render DOT data using Graphviz - proc = subprocess.Popen( - ['dot', '-Tpng'], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE - ) - output, _ = proc.communicate(dot_data.encode()) - - # Convert to PIL Image - self.original_image = Image.open(BytesIO(output)) - - # Update image display - self.update_image() - - except Exception as e: - print(f"Error rendering graph: {e}") - - # Update timestamp - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - self.timestamp_label.setText(current_time) - - def update_image(self): - if self.original_image is None: - return - - try: - # Convert PIL image to QImage directly - if self.original_image.mode == 'RGBA': - # For RGBA images, use direct conversion - qimg = QImage( - self.original_image.tobytes(), - self.original_image.width, - self.original_image.height, - self.original_image.width * 4, - QImage.Format.Format_RGBA8888 - ) - else: - # For other formats, convert to RGBA first - rgba_image = self.original_image.convert('RGBA') - qimg = QImage( - rgba_image.tobytes(), - rgba_image.width, - rgba_image.height, - rgba_image.width * 4, - QImage.Format.Format_RGBA8888 - ) - - # Create pixmap from QImage - pixmap = QPixmap.fromImage(qimg) - - # Update the zoomable label - self.image_label.update_image(pixmap) - - except Exception as e: - print(f"Error updating image: {e}") - - def closeEvent(self, event): - self.pipe_reader.stop() - self.pipe_reader.wait() - if os.path.exists(self.pipe_name): - os.remove(self.pipe_name) - event.accept() - -def create_pipe(pipe_name): - if os.path.exists(pipe_name) : - os.remove(pipe_name) - os.mkfifo(pipe_name) - -def print_usage(proc_name): - print("This is a real-time rendering tool for Graphviz graphics.") - print("Author: Hevake Lee\n") - print(f"Usage: {proc_name} /some/where/you_pipe_file\n") - -def main(): - if '-h' in sys.argv or '--help' in sys.argv: - print_usage(sys.argv[0]) - sys.exit(0) - - if len(sys.argv) != 2: - print_usage(sys.argv[0]) - sys.exit(1) - - pipe_name = sys.argv[1] - create_pipe(pipe_name) - - app = QApplication(sys.argv) - viewer = GraphvizViewer(pipe_name) - viewer.show() - sys.exit(app.exec()) - - os.remove(pipe_name) - -if __name__ == "__main__": - main() diff --git a/tools/graphviz_render/.gitignore b/tools/graphviz_render/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..bee8a64b79a99590d5303307144172cfe824fbf7 --- /dev/null +++ b/tools/graphviz_render/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/tools/graphviz_render/README_cn.md b/tools/graphviz_render/README_cn.md new file mode 100644 index 0000000000000000000000000000000000000000..103ff2f0dd579c4b36834b48fbb50d50852133c6 --- /dev/null +++ b/tools/graphviz_render/README_cn.md @@ -0,0 +1,15 @@ +第一步:安装python3及以上 + +第二步:安装依赖 + `pip3 install -r requirements.txt` + +第三步:运行工具 + `python3 ./src/main.py /tmp/gviz` (备注: /tmp/gviz是管道名,可自定义) + 如果运行报以下错误 + ![](images/image.png) + + 请安装: + `sudo apt-get install libxcb-cursor0 libxcb-xinerama0 libxcb-randr0 libxcb-util0-dev` + +第四步:往管道文件写数据 + 示例:`echo "diagraph{ A->B; B->C; A->C }" > /tmp/gviz` diff --git a/tools/graphviz_render/requirements.txt b/tools/graphviz_render/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..72e03b1c518ae7e4d7f2616a2bde70158d529d50 --- /dev/null +++ b/tools/graphviz_render/requirements.txt @@ -0,0 +1,4 @@ +Pillow==9.0.0 +Pillow==7.0.0 +Pillow==11.1.0 +PyQt5==5.15.11 diff --git a/tools/graphviz_render/src/data_processing.py b/tools/graphviz_render/src/data_processing.py new file mode 100644 index 0000000000000000000000000000000000000000..18443ddee336e9dc8a1eb8fc650f2651b60159c9 --- /dev/null +++ b/tools/graphviz_render/src/data_processing.py @@ -0,0 +1,132 @@ +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +import os +import subprocess +import threading +from io import BytesIO + +from PIL import Image +from PyQt5.QtGui import QPixmap, QImage +from PyQt5.QtCore import QThread, pyqtSignal + +class DataProcessing(QThread): + data_received = pyqtSignal(QPixmap) + + def __init__(self): + super().__init__() + # 共享数据保护 + self._lock = threading.Lock() + self._cond = threading.Condition(self._lock) # 数据变更条件变量 + + # 共享状态变量 + self._last_dot_data = '' # 最后接收的DOT数据 + self._running = True # 线程运行标志 + self._original_image = None # 原始图像缓存 + self._pending_update = False # 更新标记 + + def run(self): + """线程主循环(条件变量优化版)""" + while True: + with self._cond: + # 等待数据变更 + self._cond.wait_for(lambda: self._pending_update or (not self._running)) + + if not self._running: + break + + # 处理待更新数据 + self._process_update() + self._pending_update = False + + def _process_update(self): + """处理数据更新(受保护方法)""" + current_data = self._last_dot_data + + try: + # 生成图像(耗时操作) + image = self._render_dot(current_data) + if image: + self._original_image = image + self._update_display() + except Exception as e: + print(f"Render error: {e}") + + def _render_dot(self, dot_data: str) -> Image.Image: + """DOT数据渲染方法""" + proc = subprocess.Popen( + ['dot', '-Tpng'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + stdout, stderr = proc.communicate(dot_data.encode()) + + if proc.returncode != 0: + raise RuntimeError(f"Graphviz error: {stderr.decode()}") + + return Image.open(BytesIO(stdout)) + + + def _update_display(self): + """图像显示更新""" + if self._original_image is None: + return + + try: + # 转换为Qt兼容格式 + qimg = self._pil_to_qimage(self._original_image) + pixmap = QPixmap.fromImage(qimg) + + # 发射信号(跨线程安全) + self.data_received.emit(pixmap) + except Exception as e: + print(f"Display error: {e}") + + @staticmethod + def _pil_to_qimage(pil_img: Image.Image) -> QImage: + """PIL图像转QImage(优化版)""" + if pil_img.mode == 'RGBA': + fmt = QImage.Format_RGBA8888 + else: + pil_img = pil_img.convert('RGBA') + fmt = QImage.Format_RGBA8888 + + return QImage( + pil_img.tobytes(), + pil_img.width, + pil_img.height, + pil_img.width * 4, + fmt + ) + + def receive_data(self, dot_data: str): + """接收新数据(线程安全入口)""" + with self._cond: + if dot_data != self._last_dot_data: + self._last_dot_data = dot_data + self._pending_update = True + self._cond.notify() + + def stop(self): + """安全停止线程""" + with self._cond: + self._running = False + self._cond.notify_all() # 唤醒可能处于等待的线程 diff --git a/tools/graphviz_render/src/data_source.py b/tools/graphviz_render/src/data_source.py new file mode 100644 index 0000000000000000000000000000000000000000..c127c50ac95d7c90cce13dd3b6f38225cd0b97cc --- /dev/null +++ b/tools/graphviz_render/src/data_source.py @@ -0,0 +1,50 @@ +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +from PyQt5.QtCore import QThread, pyqtSignal + +exit_str = "pipe_read_exit" + +class DataSource(QThread): + data_received = pyqtSignal(str) + + def __init__(self, pipe_name): + super().__init__() + self.pipe_name = pipe_name + self.running = True + + def run(self): + while self.running: + try: + with open(self.pipe_name, 'r') as pipe: + while self.running: + data = pipe.read() + if data: + self.data_received.emit(data) + self.msleep(10) + + except Exception as e: + print(f"Error reading from pipe: {e}") + self.msleep(1000) + + def stop(self): + self.running = False + with open(self.pipe_name, 'w') as pipe: + pipe.write(exit_str) diff --git a/tools/graphviz_render/src/main.py b/tools/graphviz_render/src/main.py new file mode 100755 index 0000000000000000000000000000000000000000..d016feebd9bd0300dcd19b1c8870e737e4b81ced --- /dev/null +++ b/tools/graphviz_render/src/main.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 + +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +import os +import sys +import signal + +from ui.viewer import GraphvizViewer +from signal_handler import SignalHandler +from PyQt5.QtWidgets import (QApplication) + +def create_pipe(pipe_name): + if os.path.exists(pipe_name) : + os.remove(pipe_name) + os.mkfifo(pipe_name) + +def print_usage(proc_name): + print("This is a real-time rendering tool for Graphviz graphics.") + print("Author: Hevake Lee\n") + print(f"Usage: {proc_name} /some/where/you_pipe_file\n") + +def main(): + if '-h' in sys.argv or '--help' in sys.argv: + print_usage(sys.argv[0]) + sys.exit(0) + + if len(sys.argv) != 2: + print_usage(sys.argv[0]) + sys.exit(1) + + pipe_name = sys.argv[1] + create_pipe(pipe_name) + + app = QApplication(sys.argv) + viewer = GraphvizViewer(pipe_name) + handler = SignalHandler() + handler.sig_interrupt.connect(app.quit) + viewer.show() + sys.exit(app.exec()) + + os.remove(pipe_name) + +if __name__ == "__main__": + main() diff --git a/tools/graphviz_render/src/signal_handler.py b/tools/graphviz_render/src/signal_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..b4f0e6fc27ec062cd7941b57e0685c64742caf3a --- /dev/null +++ b/tools/graphviz_render/src/signal_handler.py @@ -0,0 +1,34 @@ +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +import signal +from PyQt5.QtCore import QObject, pyqtSignal, QTimer + +class SignalHandler(QObject): + sig_interrupt = pyqtSignal() + + def __init__(self): + super().__init__() + # 注册系统信号到Qt信号转发 + signal.signal(signal.SIGINT, self.handle_signal) + + def handle_signal(self, signum, _): + print(f"捕获到信号 {signum}") + self.sig_interrupt.emit() # 通过Qt信号触发主线程退出 diff --git a/tools/graphviz_render/src/transform/__init__.py b/tools/graphviz_render/src/transform/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1cff8f6baa9c4b21eaf5d0980ff8984333a5510a --- /dev/null +++ b/tools/graphviz_render/src/transform/__init__.py @@ -0,0 +1,21 @@ +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +from .zoomable import ZoomableGraphicsView diff --git a/tools/graphviz_render/src/transform/zoomable.py b/tools/graphviz_render/src/transform/zoomable.py new file mode 100644 index 0000000000000000000000000000000000000000..348c6f2f655d3851e864673ab8ecd9c8dc6b7b93 --- /dev/null +++ b/tools/graphviz_render/src/transform/zoomable.py @@ -0,0 +1,258 @@ +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +from PyQt5.QtCore import pyqtProperty +from PyQt5.QtGui import QPixmap, QWheelEvent, QMouseEvent, QKeyEvent, QPainter, QColor +from PyQt5.QtCore import Qt, QPointF, QPropertyAnimation, QTimer, QEasingCurve +from PyQt5.QtWidgets import (QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QOpenGLWidget, QGraphicsEllipseItem) + + +class ZoomableGraphicsView(QGraphicsView): + def __init__(self, parent=None): + super().__init__(parent) + + # 初始化视图设置 + self._setup_view() + self._setup_scene() + self._setup_interaction() + self._setup_indicators() + + # 初始化参数 + self._zoom_factor = 1.0 + self.min_zoom = 0.1 + self.max_zoom = 20.0 + self.dragging = False + self.fisrt_refresh = True + self.last_mouse_pos = QPointF() + + # 添加属性声明 + @pyqtProperty(float) + def zoom_factor(self): + return self._zoom_factor + + @zoom_factor.setter + def zoom_factor(self, value): + # 计算缩放比例差异 + ratio = value / self._zoom_factor + self._zoom_factor = value + # 应用缩放变换 + self.scale(ratio, ratio) + + def _setup_view(self): + """配置视图参数""" + self.setViewport(QOpenGLWidget()) # OpenGL加速 + self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform | QPainter.TextAntialiasing) + self.setCacheMode(QGraphicsView.CacheBackground) + self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) + + def _setup_scene(self): + """配置场景和图像项""" + self.scene = QGraphicsScene(self) + self.scene.setSceneRect(-1e6, -1e6, 2e6, 2e6) # 超大场景范围 + self.setScene(self.scene) + + self.pixmap_item = QGraphicsPixmapItem() + self.pixmap_item.setTransformationMode(Qt.SmoothTransformation) + self.pixmap_item.setShapeMode(QGraphicsPixmapItem.BoundingRectShape) + self.scene.addItem(self.pixmap_item) + + def _setup_interaction(self): + """配置交互参数""" + self.setDragMode(QGraphicsView.NoDrag) + self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) + self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setMouseTracking(True) + + def _setup_indicators(self): + """配置视觉指示器""" + # 居中指示器 + self.center_indicator = QGraphicsEllipseItem(-8, -8, 16, 16) + self.center_indicator.setPen(QColor(255, 0, 0, 150)) + self.center_indicator.setBrush(QColor(255, 0, 0, 50)) + self.center_indicator.setZValue(100) + self.center_indicator.setVisible(False) + self.scene.addItem(self.center_indicator) + + def update_image(self, pixmap: QPixmap): + """更新图像并自适应视图""" + self.pixmap_item.setPixmap(pixmap) + self._center_pixmap(pixmap) + if self.fisrt_refresh: + self.fisrt_refresh = False + self.fit_to_view() + + def _center_pixmap(self, pixmap: QPixmap): + """居中放置图元""" + self.pixmap_item.setPos(-pixmap.width()/2, -pixmap.height()/2) + self.scene.setSceneRect(-1e6, -1e6, 2e6, 2e6) # 重置场景范围 + + def fit_to_view(self): + """自适应窗口显示""" + if self.pixmap_item.pixmap().isNull(): + return + + # 自动计算场景中的图元边界 + rect = self.pixmap_item.sceneBoundingRect() + self.fitInView(rect, Qt.KeepAspectRatio) + self._zoom_factor = 1.0 + + def wheelEvent(self, event: QWheelEvent): + """滚轮缩放处理""" + zoom_in = event.angleDelta().y() > 0 + factor = 1.25 if zoom_in else 0.8 + new_zoom = self._zoom_factor * factor + + # 应用缩放限制 + if self.min_zoom <= new_zoom <= self.max_zoom: + self.scale(factor, factor) + self._zoom_factor = new_zoom + + def keyPressEvent(self, event: QKeyEvent): + """增加键盘快捷键支持""" + new_zoom = 0.0 + factor = 0.0 + if event.modifiers() == Qt.ControlModifier: + if event.key() == Qt.Key_Left: # Ctrl + Left + event.accept() + factor = 0.8 + new_zoom = self._zoom_factor * factor + + elif event.key() == Qt.Key_Right: # Ctrl + right + event.accept() + factor = 1.25 + new_zoom = self._zoom_factor * factor + + # 应用缩放限制 + if self.min_zoom <= new_zoom <= self.max_zoom: + self.scale(factor, factor) + self._zoom_factor = new_zoom + return + + super().keyPressEvent(event) + + def mousePressEvent(self, event: QMouseEvent): + """鼠标按下事件处理""" + # 左键拖拽 + if event.button() in (Qt.RightButton, Qt.LeftButton): + self.dragging = True + self.last_mouse_pos = event.pos() + self.setCursor(Qt.ClosedHandCursor) + + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event: QMouseEvent): + """鼠标双击事件处理(增强版)""" + if event.button() == Qt.RightButton: + event.accept() + self._fit_and_center_animation() # 改为新的组合动画方法 + return + elif event.button() == Qt.LeftButton: + event.accept() + factor = 2 + new_zoom = self._zoom_factor * factor + + # 应用缩放限制 + if self.min_zoom <= new_zoom <= self.max_zoom: + self.scale(factor, factor) + self._zoom_factor = new_zoom + return + + super().mouseDoubleClickEvent(event) + + def mouseMoveEvent(self, event: QMouseEvent): + """鼠标移动事件处理""" + if self.dragging: + delta = event.pos() - self.last_mouse_pos + self.last_mouse_pos = event.pos() + + # 更新滚动条实现拖拽 + self.horizontalScrollBar().setValue( + self.horizontalScrollBar().value() - delta.x()) + self.verticalScrollBar().setValue( + self.verticalScrollBar().value() - delta.y()) + + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent): + """鼠标释放事件处理""" + if event.button() in (Qt.RightButton, Qt.LeftButton): + self.dragging = False + self.setCursor(Qt.ArrowCursor) + super().mouseReleaseEvent(event) + + def _fit_and_center_animation(self): + """自适应并居中动画组合""" + if self.pixmap_item.pixmap().isNull(): + return + + # 先执行自适应调整 + self.fit_to_view() + + # 获取最终场景中心坐标 + final_center = self.pixmap_item.sceneBoundingRect().center() + + # 创建组合动画 + self._create_center_animation(final_center) + + def _create_center_animation(self, target_center: QPointF): + """创建居中动画序列""" + # 平移动画 + anim_h = QPropertyAnimation(self.horizontalScrollBar(), b"value") + anim_v = QPropertyAnimation(self.verticalScrollBar(), b"value") + + # 缩放动画 + current_zoom = self._zoom_factor + anim_zoom = QPropertyAnimation(self, b"zoom_factor") + anim_zoom.setDuration(400) + anim_zoom.setStartValue(current_zoom) + anim_zoom.setEndValue(1.0) # 自适应后的标准缩放值 + + # 配置动画参数 + for anim in [anim_h, anim_v]: + anim.setDuration(400) + anim.setEasingCurve(QEasingCurve.OutQuad) + + # 计算目标滚动值 + view_center = self.mapToScene(self.viewport().rect().center()) + delta = target_center - view_center + + # 设置动画参数 + anim_h.setStartValue(self.horizontalScrollBar().value()) + anim_h.setEndValue(self.horizontalScrollBar().value() + delta.x()) + + anim_v.setStartValue(self.verticalScrollBar().value()) + anim_v.setEndValue(self.verticalScrollBar().value() + delta.y()) + + # 启动动画 + anim_zoom.start() + anim_h.start() + anim_v.start() + + # 显示指示器 + self.center_indicator.setPos(target_center) + self.center_indicator.setVisible(True) + QTimer.singleShot(800, lambda: self.center_indicator.setVisible(False)) + + def resizeEvent(self, event): + """窗口大小变化时保持自适应""" + self.fit_to_view() + super().resizeEvent(event) diff --git a/tools/graphviz_render/src/transform/zoomable_cpu.py b/tools/graphviz_render/src/transform/zoomable_cpu.py new file mode 100644 index 0000000000000000000000000000000000000000..7260ced40ecb1d7e6a4f94396519537d90286e6f --- /dev/null +++ b/tools/graphviz_render/src/transform/zoomable_cpu.py @@ -0,0 +1,99 @@ +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +from PyQt5.QtWidgets import QLabel, QApplication, QOpenGLWidget +from PyQt5.QtCore import Qt, QPoint, QTime, QTimer +from PyQt5.QtGui import QPixmap, QPainter, QMouseEvent, QWheelEvent, QImage + +class ZoomableLabel(QLabel): + def __init__(self, parent=None): + super().__init__(parent) + self.zoom_factor = 1.0 + self.min_zoom = 0.1 + self.max_zoom = 10.0 + self.zoom_step = 1.2 + self.panning = False + self.last_pos = None + self.offset = QPoint(0, 0) + self.current_pixmap = None + self.setMouseTracking(True) + self.initial_fit = True + self.last_click_time = 0 + self.last_click_pos = None + self.zoom_cache = {} + self.current_cache_key = None + self.redraw_timer = QTimer(self) + self.redraw_timer.setSingleShot(True) + self.redraw_timer.timeout.connect(self.force_redraw) + self.redraw_pending = False + self.zoom_timer = QTimer(self) + self.zoom_timer.setSingleShot(True) + self.zoom_timer.timeout.connect(self.high_quality_redraw) + + def wheelEvent(self, event: QWheelEvent): + if self.current_pixmap is None: + return + cursor_pos = event.pos() + if event.angleDelta().y() > 0: + self.zoom_at_position(cursor_pos, self.zoom_step) + else: + self.zoom_at_position(cursor_pos, 1.0 / self.zoom_step) + self.zoom_timer.start(500) # 延迟高质量重绘 + + def update_image(self, pixmap: QPixmap, use_fast=False): + if pixmap is None: + return + cache_key = (round(self.zoom_factor, 2), pixmap.size().width(), pixmap.size().height(), use_fast) + if cache_key in self.zoom_cache: + scaled_pixmap = self.zoom_cache[cache_key] + else: + new_width = int(pixmap.width() * self.zoom_factor) + new_height = int(pixmap.height() * self.zoom_factor) + transformation = Qt.FastTransformation if use_fast else Qt.SmoothTransformation + scaled_pixmap = pixmap.scaled(new_width, new_height, Qt.KeepAspectRatio, transformation) + self.zoom_cache[cache_key] = scaled_pixmap + if len(self.zoom_cache) > 10: + self.zoom_cache.pop(next(iter(self.zoom_cache))) + result_pixmap = QPixmap(self.size()) + result_pixmap.fill(Qt.transparent) + painter = QPainter(result_pixmap) + x = (self.width() - scaled_pixmap.width()) // 2 + self.offset.x() + y = (self.height() - scaled_pixmap.height()) // 2 + self.offset.y() + painter.drawPixmap(x, y, scaled_pixmap) + painter.end() + super().setPixmap(result_pixmap) + + def high_quality_redraw(self): + if self.current_pixmap: + self.update_image(self.current_pixmap, use_fast=False) + + def mouseMoveEvent(self, event: QMouseEvent): + if self.panning and self.last_pos is not None: + delta = event.pos() - self.last_pos + self.offset += delta + self.last_pos = event.pos() + if not self.redraw_pending: + self.redraw_pending = True + self.redraw_timer.start(30) + + def force_redraw(self): + if self.redraw_pending and self.current_pixmap: + self.update_image(self.current_pixmap, use_fast=True) + self.redraw_pending = False diff --git a/tools/graphviz_render/src/ui/__init__.py b/tools/graphviz_render/src/ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ad8aec0193d533d38a4a5114245ad0b72514cc96 --- /dev/null +++ b/tools/graphviz_render/src/ui/__init__.py @@ -0,0 +1,21 @@ +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +from .viewer import GraphvizViewer diff --git a/tools/graphviz_render/src/ui/viewer.py b/tools/graphviz_render/src/ui/viewer.py new file mode 100644 index 0000000000000000000000000000000000000000..08d35e4d860e99882af4da7e3b7089838613ed32 --- /dev/null +++ b/tools/graphviz_render/src/ui/viewer.py @@ -0,0 +1,120 @@ +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +import os +import subprocess + +from io import BytesIO +from PIL import Image +from data_processing import DataProcessing +from data_source import DataSource +from transform.zoomable import ZoomableGraphicsView + + +from PIL import Image +from datetime import datetime +from PyQt5.QtWidgets import (QMainWindow, QLabel, QVBoxLayout,QWidget) +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QImage, QPixmap, QFont + +class GraphvizViewer(QMainWindow): + def __init__(self, pipe_name): + super().__init__() + self.pipe_name = pipe_name + self.current_pixmap = None + self.setup_ui() + + def setup_ui(self): + self.setWindowTitle(f"Graphviz: {self.pipe_name}") + self.showMaximized() + + # Create central widget and layout + central_widget = QWidget() + self.setCentralWidget(central_widget) + layout = QVBoxLayout(central_widget) + layout.setContentsMargins(0, 0, 0, 0) # Remove margins + layout.setSpacing(0) # Remove spacing + + # Create timestamp label with minimal height + self.timestamp_label = QLabel() + self.timestamp_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.timestamp_label.setStyleSheet("background-color: #f0f0f0; padding: 2px;") + self.timestamp_label.setFont(QFont("Arial", 8)) # Smaller font + self.timestamp_label.setFixedHeight(20) # Fixed height for timestamp + + # Create zoomable image label + self.image_label = ZoomableGraphicsView() + # self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + # self.image_label.setMinimumSize(1, 1) # Allow shrinking + + # Add widgets to main layout + layout.addWidget(self.timestamp_label) + layout.addWidget(self.image_label) + + self.data_processing = DataProcessing() + self.data_processing.data_received.connect(self.update_graph) + self.data_processing.start() + + # Setup pipe reader + self.data_source = DataSource(self.pipe_name) + self.data_source.data_received.connect(self.receive_graph_data) + self.data_source.start() + + # Setup resize timer for debouncing + self.resize_timer = QTimer() + self.resize_timer.setSingleShot(True) + self.resize_timer.timeout.connect(self.update_image) + + # Connect resize event + self.resizeEvent = self.on_resize + + def on_resize(self, event): + super().resizeEvent(event) + # Debounce resize events + # self.resize_timer.start(100) + + def update_graph(self, pixmap): + if pixmap != self.current_pixmap: + self.current_pixmap = pixmap + self.image_label.update_image(self.current_pixmap) + + def updateTime(self): + # Update timestamp + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.timestamp_label.setText(current_time) + + def receive_graph_data(self, data): + self.updateTime() + self.data_processing.receive_data(data) + + def update_image(self): + if self.update_image is None: + return + self.image_label.update_image(self.current_pixmap) + + def closeEvent(self, event): + self.data_source.stop() + self.data_processing.stop() + self.data_source.wait() + self.data_processing.wait() + if os.path.exists(self.pipe_name): + os.remove(self.pipe_name) + event.accept() +