6 Commits
Author SHA1 Message Date
markusro 7164531351 add toolbar-free mouse panning and zooming to the plot
Build Debian Packages / build (bookworm, debian12) (push) Successful in 11m52s
Build Debian Packages / build (trixie, debian13) (push) Successful in 12m3s
Connect matplotlib event handlers on the plot canvas so the display
can be navigated without engaging the toolbar modes: left button
drag pans the view, the mouse wheel zooms both axes around the
pointer, and right button drag zooms single axes (horizontal motion
zooms x, vertical motion zooms y, like the right button in the
toolbar pan mode). Linear (including inverted ppm) and logarithmic
axes are handled, the latter in log space. While a toolbar pan/zoom
mode is active the handlers stand back so the toolbar behavior is
unchanged. Documented in doc/manual.rst.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:43 +02:00
markusro 1bed381639 add toolbar buttons copying the plot as PNG and SVG to the clipboard
Two new buttons below the plot copy the current figure to the
clipboard. The PNG copy renders off-screen on a temporary Agg canvas
at a fixed 12:8 inches and 800 px width, independent of the window
size, and restores figure canvas, size and dpi afterwards so the
on-screen plot is untouched. The RGBA buffer is wrapped in GLib.Bytes
for GdkPixbuf.Pixbuf.new_from_bytes, which current PyGObject requires
(fixes the 'Expected GLib.Bytes, but got bytes' error). The SVG copy
keeps using savefig into a text buffer; both report through logger.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:34 +02:00
markusro 0135f9b7a3 route the unknown-data-type message through the logger
save_display_data_as_text printed via the removed log() helper;
use logger.warning instead so the message lands in the log window.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:26 +02:00
markusro 0135504de2 remove the canvas context menu
The middle-click context menu (crosshair toggle, copy as PNG/SVG)
duplicated functionality that is now reachable elsewhere: the
crosshair is toggled by its own toolbar button and the PNG/SVG
clipboard copies get dedicated toolbar buttons. Removing it frees the
middle mouse button on the plot canvas.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:22 +02:00
markusro d539a27423 ignore duplicate quit events when the window is closed twice
quit_event ran its cleanup path again when a second delete-event
arrived after the state had already switched to Quit_State, hanging
the application on shutdown. Return early instead.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:14 +02:00
markusro 34eb7b2728 cap matplotlib canvas at logical resolution for fast HiDPI rendering
The stock GTK3Agg canvas renders one buffer pixel per physical pixel,
so on a scaled (HiDPI) screen every full redraw rasterizes four times
the pixel count, which makes the interactive plot sluggish. Override
_update_device_pixel_ratio in BlitSafeCanvas to pin the device pixel
ratio at 1: the plot renders at logical resolution and the window
system scales the output up. Measured ~2.3x faster full redraws on a
2x-scaled screen; non-scaled screens are unaffected.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:15:30 +02:00
3 changed files with 200 additions and 71 deletions

No files matched your search

+2
View File
@@ -100,6 +100,8 @@ There are now two possibilities to export the figure:
1. Saving the plot as a figure (EPS, PNG, JPEG and more) by pressing the save button in the toolbar below the plot area
2. Saving the data of the plot as CSV file (comma separated values) by pressing the button on the right side of the toolbar under the plot
The plot can be navigated directly with the mouse: dragging with the left mouse button pans the view and the mouse wheel zooms in and out around the pointer position. Holding the right mouse button and dragging horizontally zooms only the x axis, dragging vertically zooms only the y axis (dragging right or up zooms in), just like the right button in the toolbar pan mode. This works on linear and logarithmic axes. The pan and zoom buttons in the toolbar below the plot are still available; while one of these toolbar modes is active, the direct mouse panning and right-button zooming are suspended so the toolbar behavior is unchanged. Note that with the autoscaling option enabled, the view is reset to the full data range whenever a new result arrives.
Help Menu
=========
+150 -69
View File
@@ -253,6 +253,8 @@ class DamarisGUI:
"on_display_y_scaling_combobox_changed": self.monitor.display_scaling_changed,
"on_display_save_data_as_text_button_clicked": self.monitor.save_display_data_as_text,
"on_display_copy_data_to_clipboard_button_clicked": self.monitor.copy_display_data_to_clipboard,
"on_display_copy_png_button_clicked": self.monitor.copy_canvas_as_png,
"on_display_copy_svg_button_clicked": self.monitor.copy_canvas_as_svg,
"on_experiment_script_textview_event": self.sw.textviews_modified,
"on_main_notebook_switch_page": self.nopevent,
"main_window_close": self.nopevent
@@ -333,6 +335,9 @@ class DamarisGUI:
"""
expecting quit event for main application
"""
if self.state == DamarisGUI.Quit_State:
# quit already handled, e.g. window closed twice
return True
if self.state in [ DamarisGUI.Edit_State, DamarisGUI.Quit_State ]:
self.state = DamarisGUI.Quit_State
# do a cleanup...
@@ -3024,6 +3029,18 @@ class BlitSafeCanvas( FigureCanvas ):
screen. Painting the region right away keeps every regular paint a
complete one and stays a partial update.
"""
def _update_device_pixel_ratio( self, *args, **kwargs ):
"""Render at logical resolution, ignoring the HiDPI scale factor.
The stock canvas renders one buffer pixel per physical pixel,
four times the pixel count on a scaled screen, which makes the
interactive plot sluggish. Capping the ratio at 1 renders a
quarter of the pixels; the window system scales the output up.
"""
if self._set_device_pixel_ratio( 1 ):
self.queue_resize( )
self.queue_draw( )
def blit( self, bbox=None ):
if bbox is None:
bbox = self.figure.bbox
@@ -3255,9 +3272,6 @@ class MonitorWidgets:
# add figure to canvas
self.matplot_canvas = BlitSafeCanvas( self.matplot_figure )
# add ctrl + right-click context menu to canvas
self.matplot_canvas.connect("button-press-event", self.on_canvas_button_press)
self.display_table = self.xml_gui.get_object( "display_table" )
self.display_table.attach( self.matplot_canvas, 0, 6, 0, 1, gtk.AttachOptions.EXPAND | gtk.AttachOptions.FILL, gtk.AttachOptions.EXPAND | gtk.AttachOptions.FILL, 0, 0 )
self.matplot_canvas.show( )
@@ -3292,6 +3306,16 @@ class MonitorWidgets:
self.display_table.attach( self.matplot_toolbar, 0, 1, 1, 2, gtk.AttachOptions.FILL | gtk.AttachOptions.EXPAND, 0, 0, 0 )
self.matplot_toolbar.show( )
# mouse pan (left button drag), single-axis zoom (middle button
# drag) and wheel zoom, active whenever no toolbar pan/zoom
# mode is on
self._pan_start = None
self._zoom_drag = None
self.matplot_canvas.mpl_connect( "scroll_event", self.plot_wheel_zoom )
self.matplot_canvas.mpl_connect( "button_press_event", self.plot_button_press )
self.matplot_canvas.mpl_connect( "motion_notify_event", self.plot_button_motion )
self.matplot_canvas.mpl_connect( "button_release_event", self.plot_button_release )
# select display source
self.display_source_combobox = self.xml_gui.get_object( "display_source_combobox" )
self.display_source_treestore = gtk.TreeStore( gobject.TYPE_STRING )
@@ -3728,7 +3752,7 @@ class MonitorWidgets:
# nothing to save
return
if not hasattr( data_to_save[ 1 ], "write_to_csv" ):
log( "do not know how to save %s of class/type %s" % (data_to_save[ 0 ], type( data_to_save[ 1 ] )) )
logger.warning( "do not know how to save %s of class/type %s" % (data_to_save[ 0 ], type( data_to_save[ 1 ] )) )
return
# save them to a temporary file (in memory)
@@ -3788,6 +3812,98 @@ class MonitorWidgets:
return True
@staticmethod
def _zoomed_limits( limits, center, factor, logarithmic ):
"""Return the limits zoomed by factor around center.
On logarithmic axes the scaling happens in log space.
"""
if logarithmic:
limits = numpy.log10( limits )
center = numpy.log10( center )
new_limits = [ center + ( limit - center ) * factor for limit in limits ]
if logarithmic:
new_limits = [ 10 ** limit for limit in new_limits ]
return tuple( new_limits )
@staticmethod
def _panned_limits( limits, grabbed, current, logarithmic ):
"""Return the limits shifted so that current moves to grabbed.
On logarithmic axes the shift happens in log space.
"""
if logarithmic:
shift = 10 ** ( numpy.log10( grabbed ) - numpy.log10( current ) )
return tuple( limit * shift for limit in limits )
shift = grabbed - current
return tuple( limit + shift for limit in limits )
def plot_wheel_zoom( self, event ):
"""Zoom the plot around the pointer position with the mouse wheel."""
if event.inaxes is not self.matplot_axes or event.xdata is None or event.ydata is None:
return
factor = 1.0 / 1.5 if event.step > 0 else 1.5
axes = self.matplot_axes
axes.set_xlim( self._zoomed_limits( axes.get_xlim( ), event.xdata, factor,
axes.get_xscale( ) == "log" ) )
axes.set_ylim( self._zoomed_limits( axes.get_ylim( ), event.ydata, factor,
axes.get_yscale( ) == "log" ) )
self.matplot_canvas.draw_idle( )
def plot_button_press( self, event ):
"""Start panning the plot with a left button drag.
A right button drag starts zooming single axes, like the right
button in the toolbar pan mode: horizontal motion zooms the x
axis, vertical motion the y axis. Both are inactive while a
toolbar pan/zoom mode is on, so the toolbar behavior stays
unchanged.
"""
if event.inaxes is not self.matplot_axes or event.xdata is None or event.ydata is None:
return
if event.button == 3 and not self.matplot_toolbar.mode:
self._zoom_drag = ( event.x, event.y, event.xdata, event.ydata )
elif event.button == 1 and not self.matplot_toolbar.mode:
self._pan_start = ( event.xdata, event.ydata )
def plot_button_motion( self, event ):
"""Pan the plot so the grabbed data point follows the pointer.
With the right button held, zoom single axes around the
pressed position instead: horizontal motion zooms the x axis,
vertical motion the y axis, dragging right or up zooms in.
"""
if self._zoom_drag is not None and event.x is not None and event.y is not None:
x0, y0, xdata0, ydata0 = self._zoom_drag
axes = self.matplot_axes
dx = event.x - x0
dy = event.y - y0
if dx:
factor = 10 ** ( -dx / axes.bbox.width )
axes.set_xlim( self._zoomed_limits( axes.get_xlim( ), xdata0, factor,
axes.get_xscale( ) == "log" ) )
if dy:
factor = 10 ** ( -dy / axes.bbox.height )
axes.set_ylim( self._zoomed_limits( axes.get_ylim( ), ydata0, factor,
axes.get_yscale( ) == "log" ) )
self.matplot_canvas.draw_idle( )
return
if self._pan_start is None or event.xdata is None or event.ydata is None:
return
x0, y0 = self._pan_start
axes = self.matplot_axes
axes.set_xlim( self._panned_limits( axes.get_xlim( ), x0, event.xdata,
axes.get_xscale( ) == "log" ) )
axes.set_ylim( self._panned_limits( axes.get_ylim( ), y0, event.ydata,
axes.get_yscale( ) == "log" ) )
self._pan_start = ( event.xdata, event.ydata )
self.matplot_canvas.draw_idle( )
def plot_button_release( self, event ):
"""Stop panning or single-axis zooming the plot."""
self._pan_start = None
self._zoom_drag = None
def toggle_cursor(self, widget):
"""Toggle matplotlib crosshair cursor"""
if widget.get_active():
@@ -3810,40 +3926,6 @@ class MonitorWidgets:
self.matplot_cursor = None
self.matplot_canvas.draw()
def on_canvas_button_press(self, widget, event):
"""Handle button press events on matplotlib canvas"""
if event.button == 2: # middle click
self.show_canvas_context_menu(event)
return True
return False
def show_canvas_context_menu(self, event):
"""Show context menu for matplotlib canvas"""
menu = gtk.Menu()
# toggle cursor
toggle_cursor = gtk.CheckMenuItem(label="Crosshair")
# Set the current state based on whether cursor exists
if hasattr(self, 'matplot_cursor') and self.matplot_cursor is not None:
toggle_cursor.set_active(True)
else:
toggle_cursor.set_active(False)
toggle_cursor.connect("toggled", lambda w: self.matplot_cursor_button.set_active(w.get_active()))
menu.append(toggle_cursor)
# Copy as PNG
item_copy_png = gtk.MenuItem(label="Copy as PNG")
item_copy_png.connect("activate", self.copy_canvas_as_png)
menu.append(item_copy_png)
# Copy as SVG
item_copy_svg = gtk.MenuItem(label="Copy as SVG")
item_copy_svg.connect("activate", self.copy_canvas_as_svg)
menu.append(item_copy_svg)
menu.show_all()
menu.popup_at_pointer(event)
def copy_canvas_as_png(self, widget):
"""Copy canvas to clipboard as PNG"""
self.copy_display_data_to_clipboard(widget)
@@ -3854,25 +3936,20 @@ class MonitorWidgets:
return
try:
# Save the original figure size
original_size = self.matplot_figure.get_size_inches()
# Save figure to a buffer as SVG
import io as iomodule
buf = iomodule.StringIO()
self.matplot_figure.set_size_inches(6, 4)
self.matplot_figure.savefig(buf, format='svg', bbox_inches='tight')
buf.seek(0)
# Copy SVG text to clipboard
clipboard = gtk.Clipboard.get(gdk.SELECTION_CLIPBOARD)
clipboard.set_text(buf.read(), -1)
clipboard.store()
self.matplot_figure.set_size_inches(original_size)
log("Figure copied to clipboard as SVG")
logger.info("Figure copied to clipboard as SVG")
except Exception as e:
log(f"Error copying figure as SVG: {e}")
logger.error("Error copying figure as SVG: %s", e)
def copy_display_data_to_clipboard( self, widget, data=None ):
"""
@@ -3880,38 +3957,42 @@ class MonitorWidgets:
Also keep text data copy functionality as fallback
"""
# First, try to copy the matplotlib figure as image
# Save the original figure size
original_size = self.matplot_figure.get_size_inches()
if self.matplot_figure is not None and self.matplot_canvas is not None:
try:
# Save figure to a temporary buffer as PNG
import io as iomodule
buf = iomodule.BytesIO()
self.matplot_figure.set_size_inches(6, 4)
self.matplot_figure.savefig(buf, format='png', dpi=300, bbox_inches='tight')
buf.seek(0)
# Load the PNG data into a GdkPixbuf
from gi.repository import GdkPixbuf
loader = GdkPixbuf.PixbufLoader.new_with_type('png')
loader.write(buf.read())
loader.close()
pixbuf = loader.get_pixbuf()
# Render off-screen at a fixed size (12:8 inches, 800 px
# wide) on a temporary Agg canvas. Snapshotting the GTK
# canvas buffer instead would copy the window size, and
# re-rendering through the GTK canvas would replace its
# renderer and leave the on-screen plot blank.
from matplotlib.backends.backend_agg import FigureCanvasAgg
figure = self.matplot_figure
orig_canvas = figure.canvas
orig_size = figure.get_size_inches( )
orig_dpi = figure.dpi
try:
agg_canvas = FigureCanvasAgg( figure )
figure.set_size_inches( 12, 8 )
figure.dpi = 800 / 12
agg_canvas.draw( )
rgba = numpy.asarray( agg_canvas.buffer_rgba( ) )
finally:
figure.set_canvas( orig_canvas )
figure.set_size_inches( *orig_size )
figure.dpi = orig_dpi
height, width = rgba.shape[ :2 ]
pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
glib.Bytes.new( rgba.tobytes( ) ), GdkPixbuf.Colorspace.RGB, True, 8,
width, height, width * 4 )
# Copy to clipboard
clipboard = gtk.Clipboard.get(gdk.SELECTION_CLIPBOARD)
clipboard.set_image(pixbuf)
clipboard.store()
# Restore original figure size
self.matplot_figure.set_size_inches(original_size)
# Redraw the canvas to ensure it stays visible
self.matplot_canvas.draw()
clipboard = gtk.Clipboard.get( gdk.SELECTION_CLIPBOARD )
clipboard.set_image( pixbuf )
log("Figure copied to clipboard as PNG image")
logger.info("Figure copied to clipboard as PNG image")
return
except Exception as e:
log("Error copying figure to clipboard: %s" % str(e))
logger.error("Error copying figure to clipboard: %s", e)
##################### functions to feed display
def clear_display( self ):
+48 -2
View File
@@ -1414,10 +1414,56 @@ Public License instead of this License.
</packing>
</child>
<child>
<placeholder/>
<object class="GtkButton" id="display_copy_png_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="tooltip-text" translatable="yes">Copy plot as PNG image to clipboard</property>
<signal name="clicked" handler="on_display_copy_png_button_clicked" swapped="no"/>
<child>
<object class="GtkLabel" id="label_copy_png">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="xpad">10</property>
<property name="label" translatable="yes">&lt;span foreground="#0000ff"&gt;PNG&lt;/span&gt;</property>
<property name="use-markup">True</property>
</object>
</child>
</object>
<packing>
<property name="left-attach">3</property>
<property name="right-attach">4</property>
<property name="top-attach">1</property>
<property name="bottom-attach">2</property>
<property name="x-options"/>
<property name="y-options">GTK_FILL</property>
</packing>
</child>
<child>
<placeholder/>
<object class="GtkButton" id="display_copy_svg_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="tooltip-text" translatable="yes">Copy plot as SVG to clipboard</property>
<signal name="clicked" handler="on_display_copy_svg_button_clicked" swapped="no"/>
<child>
<object class="GtkLabel" id="label_copy_svg">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="xpad">10</property>
<property name="label" translatable="yes">&lt;span foreground="#0000ff"&gt;SVG&lt;/span&gt;</property>
<property name="use-markup">True</property>
</object>
</child>
</object>
<packing>
<property name="left-attach">2</property>
<property name="right-attach">3</property>
<property name="top-attach">1</property>
<property name="bottom-attach">2</property>
<property name="x-options"/>
<property name="y-options">GTK_FILL</property>
</packing>
</child>
<child>
<placeholder/>