diff --git a/src/damaris/gui/DamarisGUI.py b/src/damaris/gui/DamarisGUI.py index 279d83f..ed117dd 100644 --- a/src/damaris/gui/DamarisGUI.py +++ b/src/damaris/gui/DamarisGUI.py @@ -48,6 +48,9 @@ import numpy # math graphics import matplotlib +import cairo +from matplotlib import cbook +from matplotlib.transforms import Bbox # force noninteractive matplotlib.rcParams[ "interactive" ] = "False" matplotlib.rcParams[ "text.usetex" ] = "False" @@ -3011,6 +3014,156 @@ crosshair_xpm = ( ) +class BlitSafeCanvas( FigureCanvas ): + """ + GTK3Agg canvas that paints blitted regions immediately. + + The stock blit appends the region to an internal bbox queue and the next + paint then redraws only that region; a full redraw (data update, zoom) + happening meanwhile is clipped to it, wiping the axis labels from the + screen. Painting the region right away keeps every regular paint a + complete one and stays a partial update. + """ + def blit( self, bbox=None ): + if bbox is None: + bbox = self.figure.bbox + window = self.get_window( ) + if window is None: + return + scale = self.device_pixel_ratio + allocation = self.get_allocation( ) + x = int( bbox.x0 / scale ) + y = allocation.height - int( bbox.y1 / scale ) + width = ( int( bbox.x1 ) - int( bbox.x0 ) ) // scale + height = ( int( bbox.y1 ) - int( bbox.y0 ) ) // scale + region = cairo.Region( cairo.RectangleInt( x, y, width, height ) ) + context = window.begin_draw_frame( region ) + try: + buffer = cbook._unmultiplied_rgba8888_to_premultiplied_argb32( + numpy.asarray( self.copy_from_bbox( bbox ) ) ) + image = cairo.ImageSurface.create_for_data( + buffer.ravel( ).data, cairo.FORMAT_ARGB32, + int( bbox.x1 ) - int( bbox.x0 ), int( bbox.y1 ) - int( bbox.y0 ) ) + image.set_device_scale( scale, scale ) + cr = context.get_cairo_context( ) + cr.set_source_surface( image, x, y ) + cr.paint( ) + finally: + window.end_draw_frame( context ) + + +class CrosshairCursor( matplotlib.widgets.Cursor ): + """ + Crosshair cursor with blitting that coalesces motion events to a fixed + frame rate. GTK delivers motion-notify events at the mouse's full + reporting rate (up to 1000 Hz); restoring the plot background for every + single event makes the GUI sluggish, in particular on remote X11 + connections. A frame rate limit is visually indistinguishable. + + Per frame only the thin strips around the old and the new line position + are restored, redrawn and blitted. Blitting the full axes bbox instead + requires a pixel format conversion of several megapixels per frame, + which is far too slow on HiDPI screens. + """ + def __init__( self, ax, frame_rate=60, **lineprops ): + matplotlib.widgets.Cursor.__init__( self, ax, useblit=True, **lineprops ) + self.frame_interval = 1.0 / frame_rate + self.last_frame_time = 0.0 + self._last_x = None + self._last_y = None + + def clear( self, event ): + """Refresh the saved background after a full render.""" + if self.ignore( event ): + return + if self.useblit: + # save the full figure so the saved array stays aligned with + # the canvas buffer and strips can be restored by direct + # array copy (restore_region with a bbox argument addresses + # a different coordinate frame and restores the wrong area) + self.background = self.canvas.copy_from_bbox( + self.canvas.figure.bbox ) + # the render removed the cursor lines from the buffer, but + # the screen still shows them until the repaint arrives; + # motion events processed in between would draw the cursor + # at its new position next to the stale one, so wipe the old + # line positions right away + for strip in ( self._strip_bbox( self._last_x, None, True ), + self._strip_bbox( self._last_y, None, False ) ): + if strip is not None: + self.canvas.blit( strip ) + self.linev.set_visible( False ) + self.lineh.set_visible( False ) + self._last_x = None + self._last_y = None + + def _strip_bbox( self, prev, cur, vertical ): + """Bbox covering the old and new position of one cursor line. + + The strips are padded by a pixel on each side so that integer + truncation downstream never clips a line pixel at the edges. + """ + bbox = self.ax.bbox + positions = [p for p in ( prev, cur ) if p is not None] + if not positions: + return None + if vertical: + lo = max( min( positions ) - 4, bbox.x0 ) + hi = min( max( positions ) + 5, bbox.x1 ) + if hi <= lo: + return None + return Bbox( [[lo, bbox.y0 - 1], [hi, bbox.y1 + 1]] ) + lo = max( min( positions ) - 4, bbox.y0 ) + hi = min( max( positions ) + 5, bbox.y1 ) + if hi <= lo: + return None + return Bbox( [[bbox.x0 - 1, lo], [bbox.x1 + 1, hi]] ) + + def onmove( self, event ): + if self.ignore( event ) or not self.canvas.widgetlock.available( self ): + return + now = time.monotonic( ) + if now - self.last_frame_time < self.frame_interval: + return + self.last_frame_time = now + if self.background is None: + return + if event.inaxes is self.ax and event.xdata is not None and event.ydata is not None: + self.linev.set_xdata( ( event.xdata, event.xdata ) ) + self.lineh.set_ydata( ( event.ydata, event.ydata ) ) + self.linev.set_visible( self.visible and self.vertOn ) + self.lineh.set_visible( self.visible and self.horizOn ) + new_x, new_y = event.x, event.y + else: + # pointer left the plot: hide the lines by restoring the plain + # background, no full render needed + self.linev.set_visible( False ) + self.lineh.set_visible( False ) + new_x = new_y = None + bbox = self.ax.bbox + vstrip = self._strip_bbox( self._last_x, new_x, True ) + hstrip = self._strip_bbox( self._last_y, new_y, False ) + self._last_x, self._last_y = new_x, new_y + # erase the previous lines by copying the strips back from the + # saved background + background = numpy.asarray( self.background ) + width, height = self.canvas.get_width_height( physical=True ) + buffer = numpy.frombuffer( self.canvas.buffer_rgba( ), + dtype=numpy.uint8 ).reshape( height, width, 4 ) + for strip in ( vstrip, hstrip ): + if strip is not None: + rows = slice( height - int( strip.y1 ), + height - int( strip.y0 ) + 1 ) + cols = slice( int( strip.x0 ), int( strip.x1 ) + 1 ) + buffer[rows, cols] = background[rows, cols] + if self.linev.get_visible( ) or self.lineh.get_visible( ): + self.ax.draw_artist( self.linev ) + self.ax.draw_artist( self.lineh ) + for strip in ( vstrip, hstrip ): + if strip is not None: + self.canvas.blit( strip ) + + class MonitorWidgets: """Manages the matplotlib-based data visualization panel. @@ -3100,7 +3253,7 @@ class MonitorWidgets: self.matplot_axes.set_xscale( "linear" ) # add figure to canvas - self.matplot_canvas = FigureCanvas( self.matplot_figure ) + 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) @@ -3640,8 +3793,8 @@ class MonitorWidgets: if widget.get_active(): # Activate cursor if not hasattr(self, 'matplot_cursor') or self.matplot_cursor is None: - self.matplot_cursor = matplotlib.widgets.Cursor(self.matplot_axes, - useblit=False, color='red', linewidth=1) + self.matplot_cursor = CrosshairCursor(self.matplot_axes, + color='red', linewidth=1) self.matplot_canvas.draw() # TODO: Deactivate the pane button @@ -3838,6 +3991,11 @@ class MonitorWidgets: except: pass self.clipping_marker = None + # the crosshair line artists were removed from the axes above, + # recreate the crosshair if it is enabled + if self.matplot_cursor_button.get_active( ): + self.matplot_cursor = CrosshairCursor(self.matplot_axes, + color='red', linewidth=1) self.matplot_canvas.draw_idle( ) def update_display( self, subject=None ):