diff --git a/BEdit/AGENTS.md b/BEdit/AGENTS.md index 600204b..e2cf2b6 100644 --- a/BEdit/AGENTS.md +++ b/BEdit/AGENTS.md @@ -151,6 +151,9 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant Signals tree checkboxes edit the active graph's traces, and each page embeds a Matplotlib QtAgg canvas. Each graph persists its own `x_axis` signal (default `time`), selectable from the Signals tree context menu. +- Embedded Matplotlib canvases provide cursor-centered wheel zoom and direct + left-button drag panning without activating navigation-toolbar modes. Their + custom navigation toolbar restores an explicit data-derived home view. - Rerunning the same composed model retains graph tabs, ordering, titles, X axes, and surviving trace settings while replacing numeric data. Missing signals are pruned from traces/X-axis selection and newly returned columns appear in the tree. diff --git a/BEdit/src/bedit/gui/simulation_window.py b/BEdit/src/bedit/gui/simulation_window.py index 7746ca8..f2483d2 100644 --- a/BEdit/src/bedit/gui/simulation_window.py +++ b/BEdit/src/bedit/gui/simulation_window.py @@ -1,6 +1,7 @@ import re from pathlib import Path +from matplotlib.backend_bases import MouseButton from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT from matplotlib.figure import Figure from PySide6.QtCore import Qt, Signal @@ -550,9 +551,11 @@ class GraphWorkspacePage(QWidget): self.results = results self.plot_layout = QVBoxLayout(self) self.figure = Figure(layout="constrained") - self.canvas = FigureCanvasQTAgg(self.figure) + self.canvas = InteractiveFigureCanvas(self.figure) self.axes = self.figure.add_subplot(111) - self.navigation_toolbar = NavigationToolbar2QT(self.canvas, self) + self.canvas.axes = self.axes + self.navigation_toolbar = GraphNavigationToolbar(self.canvas, self) + self.canvas.navigation_toolbar = self.navigation_toolbar self.plot_layout.addWidget(self.navigation_toolbar) self.plot_layout.addWidget(self.canvas) self.refresh_chart() @@ -578,9 +581,110 @@ class GraphWorkspacePage(QWidget): self.axes.grid(True, alpha=0.25) if self.axes.lines: self.axes.legend() + self.axes.relim() + self.axes.autoscale_view() + self.canvas.set_home_view() self.canvas.draw_idle() +class GraphNavigationToolbar(NavigationToolbar2QT): + """Navigation toolbar whose Home action includes direct canvas navigation.""" + + def home(self, *args) -> None: + del args + self.canvas.reset_home_view() + + +class InteractiveFigureCanvas(FigureCanvasQTAgg): + """Matplotlib canvas with always-available wheel zoom and drag pan.""" + + def __init__(self, figure: Figure) -> None: + super().__init__(figure) + self.axes = None + self.navigation_toolbar = None + self._pan_start = None + self._home_view = None + self.mpl_connect("scroll_event", self._zoom_at_cursor) + self.mpl_connect("button_press_event", self._start_pan) + self.mpl_connect("motion_notify_event", self._pan) + self.mpl_connect("button_release_event", self._finish_pan) + + def _toolbar_is_active(self) -> bool: + return bool( + self.navigation_toolbar is not None + and self.navigation_toolbar.mode + ) + + def set_home_view(self) -> None: + if self.axes is not None: + self._home_view = (self.axes.get_xlim(), self.axes.get_ylim()) + + def reset_home_view(self) -> None: + if self.axes is None or self._home_view is None: + return + x_limits, y_limits = self._home_view + self.axes.set_xlim(x_limits) + self.axes.set_ylim(y_limits) + self.draw_idle() + + def _zoom_at_cursor(self, event) -> None: + if ( + self.axes is None + or event.inaxes is not self.axes + or event.xdata is None + or event.ydata is None + or self._toolbar_is_active() + ): + return + scale = 0.8 if event.button == "up" else 1.25 + left, right = self.axes.get_xlim() + bottom, top = self.axes.get_ylim() + self.axes.set_xlim( + event.xdata - (event.xdata - left) * scale, + event.xdata + (right - event.xdata) * scale, + ) + self.axes.set_ylim( + event.ydata - (event.ydata - bottom) * scale, + event.ydata + (top - event.ydata) * scale, + ) + self.draw_idle() + + def _start_pan(self, event) -> None: + if ( + self.axes is None + or event.inaxes is not self.axes + or event.button != MouseButton.LEFT + or self._toolbar_is_active() + ): + return + self._pan_start = ( + event.x, + event.y, + self.axes.get_xlim(), + self.axes.get_ylim(), + ) + + def _pan(self, event) -> None: + if ( + self.axes is None + or self._pan_start is None + or event.x is None + or event.y is None + ): + return + start_x, start_y, x_limits, y_limits = self._pan_start + width = max(self.axes.bbox.width, 1.0) + height = max(self.axes.bbox.height, 1.0) + delta_x = (event.x - start_x) * (x_limits[1] - x_limits[0]) / width + delta_y = (event.y - start_y) * (y_limits[1] - y_limits[0]) / height + self.axes.set_xlim(x_limits[0] - delta_x, x_limits[1] - delta_x) + self.axes.set_ylim(y_limits[0] - delta_y, y_limits[1] - delta_y) + self.draw_idle() + + def _finish_pan(self, _event) -> None: + self._pan_start = None + + def _safe_file_stem(model_name: str) -> str: stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", model_name).strip("._") return stem or "simulation"