class RqtDotGraphViewer(Plugin): """rqt GUI plugin to visualize dot graphs.""" def __init__(self, context): """Initialize the plugin.""" super().__init__(context) self._context = context self.subscription = None self.graph = None self.filename = None # only declare the parameter if running standalone or it's the first instance if self._context.serial_number() <= 1: self._context.node.declare_parameter("title", "Dot Graph Viewer") self.title = self._context.node.get_parameter("title").value supported_formats = QImageWriter.supportedImageFormats() self.image_filter = ( ";;".join([f"*.{fo.data().decode()}" for fo in supported_formats]) + ";;*.svg") self._widget = QWidget() self.setObjectName(self.title) _, package_path = get_resource("packages", "rqt_dotgraph") ui_file = os.path.join(package_path, "share", "rqt_dotgraph", "resource", "rqt_dotgraph.ui") loadUi(ui_file, self._widget, {"DotWidget": DotWidget}) self._widget.setObjectName(self.title + "UI") self._widget.refreshButton.clicked[bool].connect( self.update_subscriber) self._widget.loadButton.clicked[bool].connect(self.load_graph) self._widget.saveButton.clicked[bool].connect(self.save_graph) title = self.title if self._context.serial_number() > 1: title += f" ({self._context.serial_number()})" self._context.add_widget(self._widget) self._widget.setWindowTitle(title) # only set main window title if running standalone if self._context.serial_number() < 1: self._widget.window().setWindowTitle(self.title) self.setup_subscription("dot_graph") def update_subscriber(self): """Update ROS 2 subscription with topic from text box.""" if self.subscription is not None: self._context.node.destroy_subscription(self.subscription) self.subscription = None self.graph = None topic = self._widget.topicText.text() self.setup_subscription(topic) def setup_subscription(self, topic): """Create the ROS 2 subscription.""" self.subscription = self._context.node.create_subscription( String, topic, self.plan_graph_callback, 10) self._widget.topicText.setText(self.subscription.topic_name) def plan_graph_callback(self, msg): """Receive the dot graph string.""" zoom_to_fit = self.graph is None self.graph = msg.data self.refresh_graph(zoom_to_fit) def refresh_graph(self, zoom_to_fit): """Update the dot graph displayed by the plugin.""" if self.graph is None: return self._context.node.get_logger().debug(self.graph) # Capture stdout and stderr and output as an info level log # because ROS2 logging levels when launching are broken. new_out = io.StringIO() new_err = io.StringIO() with contextlib.redirect_stdout(new_out): with contextlib.redirect_stderr(new_err): self._widget.xdot_widget.set_dotcode(self.graph) self._context.node.get_logger().debug(new_out.getvalue()) self._context.node.get_logger().debug(new_err.getvalue()) if zoom_to_fit: self._widget.xdot_widget.zoom_to_fit() self._widget.xdot_widget.update() def load_graph(self): """Load a dot graph from a file.""" ret = QFileDialog.getOpenFileName(self._widget, "Load graph", "untitled.dot", "Dot files (*.dot *.xdot)") if ret[0]: with open(ret[0], "r", encoding="utf8") as dotfile: self.filename = ret[0] self.graph = dotfile.read() self.refresh_graph(True) if self.subscription is not None: self.subscription.destroy() self.subscription = None def save_graph(self): """Save the current dot graph as an image.""" if self.graph is None: return ret = QFileDialog.getSaveFileName(self._widget, "Save graph as", "untitled.png", self.image_filter, "*.png") if ret[0]: _, extension = os.path.splitext(ret[0]) if extension == ".svg": gen = QSvgGenerator() gen.setFileName(ret[0]) gen.setSize(self._widget.xdot_widget.size()) gen.setViewBox(self._widget.xdot_widget.rect()) self._widget.xdot_widget.grab().save(ret[0]) self._widget.xdot_widget.render(gen) else: self._widget.xdot_widget.grab().save(ret[0]) # Qt methods def shutdown_plugin(self): """Shutdown plugin.""" def save_settings(self, plugin_settings, instance_settings): """Save settings.""" def restore_settings(self, plugin_settings, instance_settings): """Restore settings."""