diff --git a/doc/WhatsNew.rst b/doc/WhatsNew.rst index a342b32c2..0e1ea6f90 100644 --- a/doc/WhatsNew.rst +++ b/doc/WhatsNew.rst @@ -5,8 +5,8 @@ What's New Ver 5.2.0 (unreleased) ====================== - Substituted puremagic package for python-magic (works better across - platforms) -- Fixed an issue with the mouse wheel event scrolling MDI workspaces + platforms). +- Fixed an issue with the mouse wheel event scrolling MDI workspaces. - Fixed a spurious warning when moving the cursor in the Pan plugin window and a table or plot viewer is running in the channel - Made the enter_focus property default to False @@ -17,6 +17,10 @@ Ver 5.2.0 (unreleased) - Added alternate versions of installed fonts to the default font set (bold, italic, light) - Some minor bug fixes for TreeView in Gtk3 backend +- Enhanced Plot viewer can plot 1D images or from tables via Table viewer + + - Toolbar and Info (Synopsis) bars change GUIs according to which + viewer is active in the channel Ver 5.1.0 (2024-05-22) ====================== diff --git a/ginga/BaseImage.py b/ginga/BaseImage.py index a306cfaba..7eb6748c0 100644 --- a/ginga/BaseImage.py +++ b/ginga/BaseImage.py @@ -37,7 +37,7 @@ def __init__(self, metadata=None, logger=None, name=None): self.metadata.setdefault('name', None) # For callbacks - for name in ('modified', ): + for name in ['modified']: self.enable_callback(name) def get_metadata(self): diff --git a/ginga/ImageView.py b/ginga/ImageView.py index 1535cc2ad..8b55d7d42 100644 --- a/ginga/ImageView.py +++ b/ginga/ImageView.py @@ -2352,11 +2352,14 @@ def panset_pct(self, pct_x, pct_y): """ xy_mn, xy_mx = self.get_limits() + pan_x, pan_y = self.get_pan()[:2] - data_x = (xy_mn[0] + xy_mx[0]) * pct_x - data_y = (xy_mn[1] + xy_mx[1]) * pct_y + if pct_x is not None: + pan_x = (xy_mn[0] + xy_mx[0]) * pct_x + if pct_y is not None: + pan_y = (xy_mn[1] + xy_mx[1]) * pct_y - self.panset_xy(data_x, data_y) + self.panset_xy(pan_x, pan_y) def calc_pan_pct(self, pad=0, min_pct=0.0, max_pct=0.9): """Calculate values for vertical/horizontal panning by percentages @@ -2399,34 +2402,32 @@ def calc_pan_pct(self, pad=0, min_pct=0.0, max_pct=0.9): dtype=float) x, y = tr.to_(arr).T - rx1, rx2 = np.min(x), np.max(x) - ry1, ry2 = np.min(y), np.max(y) + x_min, x_max = np.min(x), np.max(x) + y_min, y_max = np.min(y), np.max(y) bbox = self.get_pan_bbox() arr = np.array(bbox, dtype=float) x, y = tr.to_(arr).T - qx1, qx2 = np.min(x), np.max(x) - qy1, qy2 = np.min(y), np.max(y) + x_lo, x_hi = np.min(x), np.max(x) + y_lo, y_hi = np.min(y), np.max(y) # this is the range of X and Y of the entire image # in the viewer (unscaled) - rng_x, rng_y = abs(rx2 - rx1), abs(ry2 - ry1) + rng_x, rng_y = abs(x_max - x_min), abs(y_max - y_min) # this is the *visually shown* range of X and Y - abs_x, abs_y = abs(qx2 - qx1), abs(qy2 - qy1) + vis_x, vis_y = abs(x_hi - x_lo), abs(y_hi - y_lo) # calculate the length of the slider arms as a ratio - ## min_pct = self.settings.get('pan_min_scroll_thumb_pct', 0.0) - ## max_pct = self.settings.get('pan_max_scroll_thumb_pct', 0.9) - xthm_pct = max(min_pct, min(abs_x / (rx2 - rx1), max_pct)) - ythm_pct = max(min_pct, min(abs_y / (ry2 - ry1), max_pct)) + xthm_pct = max(min_pct, min(vis_x / rng_x, max_pct)) + ythm_pct = max(min_pct, min(vis_y / rng_y, max_pct)) # calculate the pan position as a ratio - pct_x = min(max(0.0, abs(0.0 - rx1) / rng_x), 1.0) - pct_y = min(max(0.0, abs(0.0 - ry1) / rng_y), 1.0) + pct_x = min(max(0.0, abs(x_min) / rng_x), 1.0) + pct_y = min(max(0.0, abs(y_min) / rng_y), 1.0) - bnch = Bunch.Bunch(rng_x=rng_x, rng_y=rng_y, vis_x=abs_x, vis_y=abs_y, + bnch = Bunch.Bunch(rng_x=rng_x, rng_y=rng_y, vis_x=vis_x, vis_y=vis_y, thm_pct_x=xthm_pct, thm_pct_y=ythm_pct, pan_pct_x=pct_x, pan_pct_y=pct_y) return bnch @@ -2446,9 +2447,6 @@ def pan_by_pct(self, pct_x, pct_y, pad=0): pad : int (optional, defaults to 0) a padding amount in pixels to add to the limits when calculating - min_pct : float (optional, range 0.0:1.0, defaults to 0.0) - max_pct : float (optional, range 0.0:1.0, defaults to 0.9) - """ # Sanity check on inputs pct_x = np.clip(pct_x, 0.0, 1.0) @@ -2468,11 +2466,11 @@ def pan_by_pct(self, pct_x, pct_y, pad=0): dtype=float) x, y = tr.to_(arr).T - rx1, rx2 = np.min(x), np.max(x) - ry1, ry2 = np.min(y), np.max(y) + x_min, x_max = np.min(x), np.max(x) + y_min, y_max = np.min(y), np.max(y) - crd_x = rx1 + (pct_x * (rx2 - rx1)) - crd_y = ry1 + (pct_y * (ry2 - ry1)) + crd_x = x_min + (pct_x * (x_max - x_min)) + crd_y = y_min + (pct_y * (y_max - y_min)) pan_x, pan_y = tr.from_((crd_x, crd_y)) self.logger.debug("crd=%f,%f pan=%f,%f" % ( diff --git a/ginga/gtk3w/GtkHelp.py b/ginga/gtk3w/GtkHelp.py index 2d94c4470..d8cdec2d6 100644 --- a/ginga/gtk3w/GtkHelp.py +++ b/ginga/gtk3w/GtkHelp.py @@ -1727,7 +1727,20 @@ def get_scroll_info(event): return (num_degrees, direction) -def get_icon(iconpath, size=None, adjust_width=True): +def get_image(iconpath, size=None, adjust_width=True): + """Get a GdkPixbuf that can be used in a button or label. + + Parameters + ---------- + iconpath : str + The path to the file containing the image of the icon + + size : tuple of int (width, height) or None, (defaults to (24, 24)) + The size of the icon to be returned in pixels + + adjust_width : bool, (optional, defaults to True) + If True, adjust width to account for the aspect ratio of the image + """ if size is not None: wd, ht = size else: @@ -1744,6 +1757,9 @@ def get_icon(iconpath, size=None, adjust_width=True): return pixbuf +get_icon = get_image + + def get_font(font_family, point_size): font_family = font_asst.resolve_alias(font_family, font_family) font = Pango.FontDescription('%s %d' % (font_family, point_size)) diff --git a/ginga/gtk3w/ImageViewGtk.py b/ginga/gtk3w/ImageViewGtk.py index 6080a1496..5a5b56882 100644 --- a/ginga/gtk3w/ImageViewGtk.py +++ b/ginga/gtk3w/ImageViewGtk.py @@ -40,6 +40,7 @@ def __init__(self, logger=None, rgbmap=None, settings=None, render=None): if render is None: render = self.t_.get('render_widget', 'widget') self.wtype = render + self.needs_scrolledview = True self.surface = None if self.wtype == 'widget': imgwin = Gtk.DrawingArea() @@ -738,7 +739,7 @@ def __init__(self, viewer, parent=None): self._adjusting = False self._scrolling = False - self.pad = 20 + self.pad = 0 self.sb_thickness = 20 self.rng_x = 100.0 self.rng_y = 100.0 diff --git a/ginga/gw/PlotView.py b/ginga/gw/PlotView.py index 8195426ae..e0444c698 100644 --- a/ginga/gw/PlotView.py +++ b/ginga/gw/PlotView.py @@ -5,143 +5,151 @@ # Please see the file LICENSE.txt for details. # import logging +from io import BytesIO import numpy as np -from ginga.misc import Callback, Settings -from ginga import AstroImage +from ginga import Mixins +from ginga.misc import Callback, Settings, Bunch +from ginga.AstroImage import AstroImage +from ginga.plot.Plotable import Plotable +from ginga.canvas.CanvasObject import get_canvas_types +from ginga.cursors import cursor_info +from ginga import events, AutoCuts -from ginga.gw import Widgets try: - from ginga.gw import Plot - from ginga.util import plots + from matplotlib.figure import Figure + from matplotlib.backend_tools import Cursors + + from ginga.gw.Plot import PlotWidget have_mpl = True except ImportError: have_mpl = False +__all__ = ['PlotViewBase', 'PlotViewEvent', 'PlotViewGw'] + -class PlotViewGw(Callback.Callbacks): +class PlotViewBase(Callback.Callbacks): """A Ginga viewer for displaying 2D plots using matplotlib. """ vname = 'Ginga Plot' - vtypes = [AstroImage.AstroImage] + vtypes = [AstroImage, Plotable] @classmethod def viewable(cls, dataobj): """Test whether `dataobj` is viewable by this viewer.""" - if not isinstance(dataobj, AstroImage.AstroImage): + if isinstance(dataobj, Plotable): + return True + if not isinstance(dataobj, AstroImage): return False shp = list(dataobj.shape) + # comment this check to be able to view 2D images with this viewer if 0 in shp or len(shp) != 1: return False return True - def __init__(self, logger=None, settings=None): + def __init__(self, logger=None, settings=None, figure=None): Callback.Callbacks.__init__(self) + if not have_mpl: + raise ImportError("Install 'matplotlib' to use this viewer") + if logger is not None: self.logger = logger else: self.logger = logging.Logger('PlotView') - - self._dataobj = None + self.needs_scrolledview = True # Create settings and set defaults + # NOTE: typically inheriting channel settings if settings is None: settings = Settings.SettingGroup(logger=self.logger) self.settings = settings - self.settings.add_defaults(plot_bg='white', show_marker=False, - linewidth=1, linestyle='-', - linecolor='blue', markersize=6, - markerwidth=0.5, markercolor='red', - markerstyle='o', file_suffix='.png') + self.t_ = settings + + self.t_.add_defaults(plot_bg='white', + plot_save_suffix='.png', + plot_dpi=100, plot_save_dpi=100, + plot_title_fontsize=14, + plot_axis_fontsize=14, + plot_limits=None, + plot_range=None, + plot_enter_focus=True, + plot_zoom_rate=1.1) + + # pan position + self.t_.add_defaults(plot_pan=(0.0, 0.0)) + self.t_.get_setting('plot_pan').add_callback('set', self.pan_change_cb) + + # for axis scaling + self.t_.add_defaults(plot_dist_axis=('linear', 'linear')) + for name in ['plot_dist_axis']: + self.t_.get_setting(name).add_callback('set', self.axis_dist_change_cb) + + # plot markers + self.t_.add_defaults(plot_show_marker=False, plot_marker_size=5, + plot_marker_width=0.35, + plot_marker_color='blue', + plot_marker_style='o') + for name in ['plot_show_marker', 'plot_marker_size', + 'plot_marker_width', 'plot_marker_color', + 'plot_marker_style']: + self.t_.get_setting(name).add_callback('set', + lambda setting, value: self.replot()) + + # TODO + width, height = 500, 500 + + if figure is None: + figure = Figure() + dpi = figure.get_dpi() + if dpi is None or dpi < 0.1: + dpi = self.t_['plot_dpi'] + wd_in, ht_in = float(width) / dpi, float(height) / dpi + figure.set_size_inches(wd_in, ht_in) + self.figure = figure + if hasattr(self.figure, 'set_tight_layout'): + self.figure.set_tight_layout(True) + + self._dataobj = None + self._data_limits = None + self.rgb_order = 'RGBA' # for debugging self.name = str(self) + # cursors + self.cursor = {} - if not have_mpl: - raise ImportError('Install matplotlib to use this plugin') - - top = Widgets.VBox() - top.set_border_width(4) + self.plot_w = PlotWidget(self) - self.line_plot = plots.Plot(logger=self.logger, - width=400, height=400) + self.artist_dct = dict() bg = self.settings.get('plot_bg', 'white') - self.line_plot.add_axis(facecolor=bg) - self.plot_w = Plot.PlotWidget(self.line_plot) - self.plot_w.resize(400, 400) - - # enable interactivity in the plot - self.line_plot.connect_ui() - self.line_plot.enable(zoom=True, pan=True) - self.line_plot.add_callback('limits-set', self.limits_cb) - - ax = self.line_plot.ax - ax.grid(True) - - top.add_widget(self.plot_w, stretch=1) - - captions = (('Log X', 'checkbutton', 'Log Y', 'checkbutton', - 'Show Marker', 'checkbutton'), - ('X Low:', 'label', 'x_lo', 'entry', - 'X High:', 'label', 'x_hi', 'entry', - 'Reset X', 'button'), - ('Y Low:', 'label', 'y_lo', 'entry', - 'Y High:', 'label', 'y_hi', 'entry', - 'Reset Y', 'button'), - ('Save', 'button')) - # for now... - orientation = 'vertical' - w, b = Widgets.build_info(captions, orientation=orientation) - self.w = b + self.ax = self.figure.add_subplot(111, facecolor=bg) + self.ax.grid(True) - top.add_widget(w, stretch=0) - - b.log_x.set_state(self.line_plot.logx) - b.log_x.add_callback('activated', self.log_x_cb) - b.log_x.set_tooltip('Plot X-axis in log scale') - - b.log_y.set_state(self.line_plot.logy) - b.log_y.add_callback('activated', self.log_y_cb) - b.log_y.set_tooltip('Plot Y-axis in log scale') - - b.x_lo.add_callback('activated', lambda w: self.set_xlim_cb()) - b.x_lo.set_tooltip('Set X lower limit') - - b.x_hi.add_callback('activated', lambda w: self.set_xlim_cb()) - b.x_hi.set_tooltip('Set X upper limit') - - b.y_lo.add_callback('activated', lambda w: self.set_ylim_cb()) - b.y_lo.set_tooltip('Set Y lower limit') - - b.y_hi.add_callback('activated', lambda w: self.set_ylim_cb()) - b.y_hi.set_tooltip('Set Y upper limit') - - b.reset_x.add_callback('activated', lambda w: self.reset_xlim_cb()) - b.reset_x.set_tooltip('Autoscale X limits') - - b.reset_y.add_callback('activated', lambda w: self.reset_ylim_cb()) - b.reset_y.set_tooltip('Autoscale Y limits') - - b.show_marker.set_state(self.settings.get('show_marker', False)) - b.show_marker.add_callback('activated', self.set_marker_cb) - b.show_marker.set_tooltip('Mark data points') - - # Button to save plot - self.save_plot = b.save - self.save_plot.set_tooltip('Save table plot') - self.save_plot.add_callback('activated', lambda w: self.save_cb()) - self.save_plot.set_enabled(False) - - self.widget = top + self.dc = get_canvas_types() + klass = AutoCuts.get_autocuts('zscale') + self.autocuts = klass(self.logger) # For callbacks - for name in ['image-set']: + for name in ['image-set', 'image-unset', + 'limits-set', 'range-set', 'redraw', + 'configure']: self.enable_callback(name) + def get_figure(self): + return self.figure + def get_widget(self): - return self.widget + # same as self.plot_w + return self.figure.canvas + + def add_axis(self, **kwdargs): + self.ax = self.figure.add_subplot(111, **kwdargs) + return self.ax + + def get_axis(self): + return self.ax def get_settings(self): return self.settings @@ -149,223 +157,973 @@ def get_settings(self): def get_logger(self): return self.logger - def clear(self): - self.widget.clear() - def initialize_channel(self, fv, channel): # no housekeeping to do (for now) on our part, just override to # suppress the logger warning pass def set_dataobj(self, dataobj): + """Set 1D data to be displayed. + + If there is no error, the ``'image-unset'`` and ``'image-set'`` + callbacks will be invoked. + + Parameters + ---------- + dataobj : `~ginga.AstroImage.AstroImage` or `~ginga.plot.Plotable.Plotable` + AstroImage object or Plotable. + + """ if not self.viewable(dataobj): - raise ValueError("Can't display this data object") + raise ValueError("Wrong type of object to load: %s" % ( + str(type(dataobj)))) + old_dataobj = self._dataobj + if old_dataobj is not None: + self.make_callback('image-unset', old_dataobj) self._dataobj = dataobj + self.clear() + + if isinstance(dataobj, AstroImage): + dataobj.add_callback('modified', self.show_image) + self.show_image(dataobj) + + elif isinstance(dataobj, Plotable): + dataobj.add_callback('modified', self.show_plotable) + self.show_plotable(dataobj) - self.do_plot(reset_xlimits=True, reset_ylimits=True) + self.zoom_fit() self.make_callback('image-set', dataobj) def get_dataobj(self): return self._dataobj - def clear_data(self): - """Clear comboboxes and columns.""" - self.w.x_lo.set_text('') - self.w.x_hi.set_text('') - self.w.y_lo.set_text('') - self.w.y_hi.set_text('') - - def clear_plot(self): + def clear(self, redraw=True): """Clear plot display.""" - self.line_plot.clear() - self.line_plot.draw() - self.save_plot.set_enabled(False) + self.clear_data() + self.t_['plot_range'] = None + if redraw: + self.redraw() - def do_plot(self, reset_xlimits=True, reset_ylimits=True): + def clear_data(self): + self.logger.debug('clearing viewer...') + self.artist_dct = dict() + self.ax.set_aspect('auto') + self.ax.cla() + self.t_['plot_limits'] = None + self._data_limits = None + + def remove_artist_category(self, artist_category, redraw=True): + artists = self.artist_dct.setdefault(artist_category, []) + for artist in artists: + try: + artist.remove() + except Exception as e: + pass + if redraw: + self.redraw() + + def set_titles(self, title=None, x_axis=None, y_axis=None, redraw=True): + if x_axis is not None: + self.ax.set_xlabel(x_axis) + if y_axis is not None: + self.ax.set_ylabel(y_axis) + if title is not None: + self.ax.set_title(title) + ax = self.ax + ax.title.set_fontsize(self.t_['plot_title_fontsize']) + for item in ([ax.xaxis.label, ax.yaxis.label] + + ax.get_xticklabels() + ax.get_yticklabels()): + item.set_fontsize(self.t_['plot_axis_fontsize']) + + # Make x axis labels a little more readable + lbls = self.ax.xaxis.get_ticklabels() + for lbl in lbls: + lbl.set(rotation=45, horizontalalignment='right') + + if redraw: + self.redraw() + + def set_grid(self, tf): + self.ax.grid(tf) + + def _record_limits(self, x_data, y_data): + x_min, x_max = np.nanmin(x_data), np.nanmax(x_data) + y_min, y_max = np.nanmin(y_data), np.nanmax(y_data) + + adjusted = False + if self._data_limits is None: + x_lo, y_lo, x_hi, y_hi = x_min, y_min, x_max, y_max + adjusted = True + else: + (x_lo, y_lo), (x_hi, y_hi) = self._data_limits + if x_min < x_lo: + x_lo, adjusted = x_min, True + if x_max > x_hi: + x_hi, adjusted = x_max, True + if y_min < y_lo: + y_lo, adjusted = y_min, True + if y_max > y_hi: + y_hi, adjusted = y_max, True + + if adjusted: + self._data_limits = [(x_lo, y_lo), (x_hi, y_hi)] + + def _plot_line(self, x_data, y_data, artist_category='default', name=None, + linewidth=1, linestyle='-', color='black', + alpha=1.0): """Simple line plot.""" - self.clear_plot() - if self._dataobj is None: # No data to plot + plt_kw = {'lw': linewidth, + 'ls': linestyle, + 'color': color, + 'alpha': alpha, + 'antialiased': True, + } + if self.t_['plot_show_marker']: + plt_kw.update({'marker': self.t_['plot_marker_style'], + 'ms': self.t_['plot_marker_size'], + 'mew': self.t_['plot_marker_width'], + 'mfc': self.t_['plot_marker_color'], + 'mec': self.t_['plot_marker_color']}) + + try: + artists = self.artist_dct.setdefault(artist_category, []) + line, = self.ax.plot(x_data, y_data, **plt_kw) + artists.append(line) + + # adjust limits if necessary + self._record_limits(x_data, y_data) + + except Exception as e: + self.logger.error(str(e), exc_info=True) + + def _plot_text(self, x, y, text, artist_category='default', rot_deg=0, + linewidth=1, color='black', alpha=1.0, + font='sans', fontsize=12): + artists = self.artist_dct.setdefault(artist_category, []) + fontdict = dict(color=color, family=font, size=fontsize) + text = self.ax.text(x, y, text, color=color, rotation=rot_deg, + fontdict=fontdict, clip_on=True) + artists.append(text) + + # adjust limits if necessary + self._record_limits(x, y) + + def _plot_normimage(self, dataobj, artist_category='default', rot_deg=0): + artists = self.artist_dct.setdefault(artist_category, []) + # Get the data extents + x0, y0 = 0, 0 + data_np = dataobj.get_data() + y1, x1 = data_np.shape[:2] + # flipx, flipy, swapxy = self.get_transforms() + # if swapxy: + # x0, x1, y0, y1 = y0, y1, x0, x1 + + locut, hicut = self.autocuts.calc_cut_levels_data(data_np) + + extent = (x0, x1, y0, y1) + img = self.ax.imshow(data_np, origin='lower', + interpolation='none', + norm='linear', # also 'log', + vmin=locut, vmax=hicut, + cmap='gray', + extent=extent) + artists.append(img) + + # adjust limits if necessary + self._record_limits(np.array((x0, x1)), np.array((y0, y1))) + + def set_limits(self, limits): + self.settings.set(plot_limits=limits) + + # NOTE: make compatible callback to image viewer + self.make_callback('limits-set', limits) + + def get_limits(self): + limits = self.settings['plot_limits'] + + if limits is None: + # No user defined limits. Return limits set from data points. + if self._data_limits is None: + return [[0.0, 0.0], [0.0, 0.0]] + + limits = self._data_limits.copy() + + return limits + + def reset_limits(self): + """Reset the bounding box of the viewer extents. + + Parameters + ---------- + None + """ + self.t_.set(plot_limits=None) + + def get_pan_bbox(self): + """Get the coordinates in the actual data corresponding to the + area shown in the display for the current zoom level and pan. + + Returns + ------- + points : list + Coordinates in the form of + ``[(x0, y0), (x1, y1), (x2, y2), (x3, y3)]`` + from lower-left to lower-right. + + """ + (x_lo, x_hi), (y_lo, y_hi) = self.get_ranges() + return [(x_lo, y_lo), (x_hi, y_lo), (x_hi, y_hi), (x_lo, y_hi)] + + def set_ranges(self, x_range=None, y_range=None): + adjusted = False + ranges = self.get_ranges() + (x_lo, x_hi), (y_lo, y_hi) = ranges + + if x_range is not None: + x_lo, x_hi = x_range + ranges[0] = [x_lo, x_hi] + adjusted = True + self.ax.set_xlim(x_lo, x_hi) + + if y_range is not None: + y_lo, y_hi = y_range + ranges[1] = [y_lo, y_hi] + adjusted = True + self.ax.set_ylim(y_lo, y_hi) + + if adjusted: + self.t_['plot_range'] = ranges + self.make_callback('range-set', ranges.copy()) + + _pan_x, _pan_y = self.t_['plot_pan'][:2] + pan_x, pan_y = (x_lo + x_hi) * 0.5, (y_lo + y_hi) * 0.5 + if not np.isclose(_pan_x, pan_x) or not np.isclose(_pan_y, pan_y): + # need to set the pan position because range is changing + # not according to pan position + #self.t_['plot_pan'] = (pan_x, pan_y) + self.set_pan(pan_x, pan_y) + + #self.make_callback('range-set', ranges.copy()) + + self.redraw() + + def get_ranges(self): + ranges = self.settings['plot_range'] + if ranges is None: + (x_lo, y_lo), (x_hi, y_hi) = self.get_limits() + return [[x_lo, x_hi], [y_lo, y_hi]] + + #return [(x_lo, x_hi), (y_lo, y_hi)] + return ranges.copy() + + def zoom_fit(self, axis='xy', no_reset=False): + """Zoom to fit display window. + Pan the image and scale the view to fit the size of the set + limits (usually set to the image size). Parameter `axis` can + be used to set which axes are allowed to be scaled; + Also see :meth:`zoom_to`. + + Parameters + ---------- + axis : str + One of: 'x', 'y', or 'xy' (default). + + no_reset : bool + Do not reset ``autozoom`` setting. + + """ + # calculate actual width of the limits/image, considering rotation + (x_min, y_min), (x_max, y_max) = self.get_limits() + (x_lo, x_hi), (y_lo, y_hi) = self.get_ranges() + + # account for t_[scale_x/y_base] + if axis in ['x', 'xy']: + x_lo, x_hi = x_min, x_max + if axis in ['y', 'xy']: + y_lo, y_hi = y_min, y_max + + self.set_ranges(x_range=(x_lo, x_hi), y_range=(y_lo, y_hi)) + + if self.t_['autozoom'] == 'once': + self.t_.set(autozoom='off') + + def set_dist_axis(self, x_axis=None, y_axis=None): + if x_axis is None: + x_axis = self.t_['plot_dist_axis'][0] + if y_axis is None: + y_axis = self.t_['plot_dist_axis'][1] + + self.t_.set(plot_dist_axis=(x_axis, y_axis)) + + def axis_dist_change_cb(self, setting, value): + x_axis, y_axis = value + if x_axis is not None: + self.ax.set_xscale(x_axis) + if y_axis is not None: + self.ax.set_yscale(y_axis) + + self.redraw() + + def redraw_now(self, whence=0): + self.figure.canvas.draw() + + self.make_callback('redraw', whence) + + def redraw(self, whence=0): + self.redraw_now(whence=whence) + + def render_canvas(self, canvas): + for obj in canvas.objects: + if isinstance(obj, self.dc.Path): + data_x, data_y = obj.points.T + self._plot_line(data_x, data_y, linewidth=obj.linewidth, + linestyle=obj.linestyle, color=obj.color, + alpha=obj.alpha) + + elif isinstance(obj, self.dc.Line): + data_x, data_y = [obj.x1, obj.x2], [obj.y1, obj.y2] + self._plot_line(data_x, data_y, linewidth=obj.linewidth, + linestyle=obj.linestyle, color=obj.color, + alpha=obj.alpha) + + elif isinstance(obj, self.dc.Text): + self._plot_text(obj.x, obj.y, text=obj.text, + rot_deg=obj.rot_deg, + color=obj.color, fontsize=obj.fontsize) + + elif isinstance(obj, self.dc.NormImage): + image = obj.get_image() + self._plot_normimage(image, rot_deg=obj.rot_deg) + + elif isinstance(obj, self.dc.Canvas): + self.render_canvas(obj) + + def show_plotable(self, plotable): + canvas = plotable.get_canvas() + self.render_canvas(canvas) + + titles = plotable.titles + self.set_titles(title=titles.title, + x_axis=titles.x_axis, y_axis=titles.y_axis, + redraw=False) + self.set_grid(plotable.grid) + + self.redraw() + + def show_image(self, image): + data_np = image.get_data() + + self.set_grid(False) + if len(data_np.shape) == 1: + x_data = np.arange(len(data_np)) + self.set_titles(title=image.get('name', "NoName 1D data"), + x_axis="Index", y_axis="Value", + redraw=False) + self._plot_line(x_data, data_np) + + elif len(data_np.shape) == 2: + self.set_titles(title=image.get('name', "NoName 2D data"), + x_axis="X", y_axis="Y", + redraw=False) + self._plot_normimage(image) + + self.redraw() + + def replot(self): + dataobj = self._dataobj + if dataobj is None: return - plt_kw = { - 'lw': self.settings.get('linewidth', 1), - 'ls': self.settings.get('linestyle', '-'), - 'color': self.settings.get('linecolor', 'blue'), - 'ms': self.settings.get('markersize', 6), - 'mew': self.settings.get('markerwidth', 0.5), - 'mfc': self.settings.get('markercolor', 'red')} - plt_kw['mec'] = plt_kw['mfc'] + (x_lo, x_hi), (y_lo, y_hi) = self.get_ranges() + self.clear_data() - try: - x_data, y_data = self.get_plot_data() - marker = self.get_marker() + if isinstance(dataobj, AstroImage): + self.show_image(dataobj) - self.line_plot.plot( - x_data, y_data, - xtitle=self.get_label('x'), ytitle=self.get_label('y'), - marker=marker, **plt_kw) + elif isinstance(dataobj, Plotable): + self.show_plotable(dataobj) - if not reset_xlimits: - self.set_xlim_cb() - self.set_xlimits_widgets() + self.set_ranges(x_range=(x_lo, x_hi), y_range=(y_lo, y_hi)) - if not reset_ylimits: - self.set_ylim_cb() - self.set_ylimits_widgets() + def get_pan(self, coord='data'): + """Get pan positions. + + Parameters + ---------- + coord : {'data'} + For compatibility with the image viewer. + + Returns + ------- + positions : tuple + X and Y positions, in that order. - except Exception as e: - self.logger.error(str(e)) - else: - self.save_plot.set_enabled(True) - - def set_xlimits_widgets(self, set_min=True, set_max=True): - """Populate axis limits GUI with current plot values.""" - xmin, xmax = self.line_plot.ax.get_xlim() - if set_min: - self.w.x_lo.set_text('{0}'.format(xmin)) - if set_max: - self.w.x_hi.set_text('{0}'.format(xmax)) - - def set_ylimits_widgets(self, set_min=True, set_max=True): - """Populate axis limits GUI with current plot values.""" - ymin, ymax = self.line_plot.ax.get_ylim() - if set_min: - self.w.y_lo.set_text('{0}'.format(ymin)) - if set_max: - self.w.y_hi.set_text('{0}'.format(ymax)) - - def limits_cb(self, plot, dct): - """Callback that is called when the limits are set by the - plot object. """ - self.set_xlimits_widgets() - self.set_ylimits_widgets() + if coord != 'data': + raise ValueError("`coord` must be 'data' with this viewer") + return self.t_['plot_pan'] - def get_plot_data(self): - """Extract only good data point for plotting.""" - y_data = self._dataobj.get_data() - x_data = np.arange(len(y_data)) + def set_pan(self, pan_x, pan_y, coord='data', no_reset=False): + """Set pan position. - return x_data, y_data + Parameters + ---------- + pan_x, pan_y : float + Pan positions in X and Y. - def get_marker(self): - _marker_type = self.settings.get('markerstyle', 'o') + coord : {'data', 'wcs'} + Indicates whether the given pan positions are in data or WCS space. - if not self.w.show_marker.get_state(): - _marker_type = None + no_reset : bool + Do not reset ``autocenter`` setting. - return _marker_type + """ + self.t_.set(plot_pan=(pan_x, pan_y)) - def get_label(self, axis): - """Return plot label for the given axis.""" + def pan_change_cb(self, setting, value): + pan_x, pan_y = value - if axis == 'x': - label = 'Index' - if axis == 'y': - label = 'Value' + (x_lo, x_hi), (y_lo, y_hi) = self.get_ranges() + x_delta, y_delta = (x_hi - x_lo) * 0.5, (y_hi - y_lo) * 0.5 + x1, x2 = pan_x - x_delta, pan_x + x_delta + y1, y2 = pan_y - y_delta, pan_y + y_delta - return label + self.set_ranges(x_range=(x1, x2), y_range=(y1, y2)) - def log_x_cb(self, w, val): - """Toggle linear/log scale for X-axis.""" - self.line_plot.logx = val - self.do_plot() + def panset_xy(self, data_x, data_y, no_reset=False): + """Similar to :meth:`set_pan`, except that input pan positions + are always in data space. - def log_y_cb(self, w, val): - """Toggle linear/log scale for Y-axis.""" - self.line_plot.logy = val - self.do_plot() + """ + self.set_pan(data_x, data_y, coord='data', no_reset=no_reset) - def set_xlim_cb(self, redraw=True): - """Set plot limit based on user values.""" - try: - xmin = float(self.w.x_lo.get_text()) - except Exception: - set_min = True - else: - set_min = False + def pan_delta_px(self, x_delta_px, y_delta_px): + """Pan by a delta in X and Y specified in pixels. - try: - xmax = float(self.w.x_hi.get_text()) - except Exception: - set_max = True - else: - set_max = False + Parameters + ---------- + x_delta_px : float + Delta pixels in X - if set_min or set_max: - self.line_plot.draw() - self.set_xlimits_widgets(set_min=set_min, set_max=set_max) + y_delta_px : float + Delta pixels in Y - if not (set_min and set_max): - self.line_plot.ax.set_xlim(xmin, xmax) - if redraw: - self.line_plot.draw() + """ + pan_x, pan_y = self.get_pan(coord='data')[:2] + pan_x += x_delta_px + pan_y += y_delta_px + self.panset_xy(pan_x, pan_y) - def set_ylim_cb(self, redraw=True): - """Set plot limit based on user values.""" - try: - ymin = float(self.w.y_lo.get_text()) - except Exception: - set_min = True - else: - set_min = False + def panset_pct(self, pct_x, pct_y): + """Similar to :meth:`set_pan`, except that pan positions + are determined by multiplying data dimensions with the given + scale factors, where 1 is 100%. - try: - ymax = float(self.w.y_hi.get_text()) - except Exception: - set_max = True - else: - set_max = False + """ + xy_mn, xy_mx = self.get_limits() + pan_x, pan_y = self.get_pan()[:2] + + if pct_x is not None: + pan_x = (xy_mn[0] + xy_mx[0]) * pct_x + if pct_y is not None: + pan_y = (xy_mn[1] + xy_mx[1]) * pct_y + + self.panset_xy(pan_x, pan_y) + + def calc_pan_pct(self, pad=0, min_pct=0.0, max_pct=0.9): + """Calculate values for vertical/horizontal panning by percentages + from the current pan position. Mostly used by scrollbar callbacks. + + Parameters + ---------- + pad : int (optional, defaults to 0) + a padding amount in pixels to add to the limits when calculating + + min_pct : float (optional, range 0.0:1.0, defaults to 0.0) + max_pct : float (optional, range 0.0:1.0, defaults to 0.9) + + Returns + ------- + res : `~ginga.misc.Bunch.Bunch` + calculation results, which include the following attributes: + - rng_x : the range of X of the limits (including padding) + - rng_y : the range of Y of the limits (including padding) + - vis_x : the visually shown range of X in the viewer + - vis_y : the visually shown range of Y in the viewer + - thm_pct_x : the length of a X scrollbar arm as a ratio + - thm_pct_y : the length of a Y scrollbar arm as a ratio + - pan_pct_x : the pan position of X as a ratio + - pan_pct_y : the pan position of Y as a ratio - if set_min or set_max: - self.line_plot.draw() - self.set_ylimits_widgets(set_min=set_min, set_max=set_max) + """ + limits = self.get_limits() + pan_x, pan_y = self.get_pan()[:2] + + # calculate the corners of the entire image in unscaled cartesian + mxwd, mxht = limits[1][:2] + mxwd, mxht = mxwd + pad, mxht + pad + mnwd, mnht = limits[0][:2] + mnwd, mnht = mnwd - pad, mnht - pad + + arr = np.array([(mnwd, mnht), (mxwd, mnht), + (mxwd, mxht), (mnwd, mxht)], + dtype=float) + x, y = arr.T + x, y = x - pan_x, y - pan_y + x_min, x_max = np.min(x), np.max(x) + y_min, y_max = np.min(y), np.max(y) + + # this is the range of X and Y of the entire image + # in the viewer (unscaled) + rng_x, rng_y = abs(x_max - x_min), abs(y_max - y_min) + + # this is the *visually shown* range of X and Y + (x_lo, x_hi), (y_lo, y_hi) = self.get_ranges() + vis_x, vis_y = x_hi - x_lo, y_hi - y_lo + arr = np.array([(x_lo, y_lo), (x_hi, y_hi)]) + x, y = arr.T + x, y = x - pan_x, y - pan_y + + # calculate the length of the slider arms as a ratio + xthm_pct = max(min_pct, min(vis_x / rng_x, max_pct)) + ythm_pct = max(min_pct, min(vis_y / rng_y, max_pct)) + + # calculate the pan position as a ratio + pct_x = min(max(0.0, abs(x_min) / rng_x), 1.0) + pct_y = min(max(0.0, abs(y_min) / rng_y), 1.0) + + bnch = Bunch.Bunch(rng_x=rng_x, rng_y=rng_y, vis_x=vis_x, vis_y=vis_y, + thm_pct_x=xthm_pct, thm_pct_y=ythm_pct, + pan_pct_x=pct_x, pan_pct_y=pct_y) + return bnch + + def pan_by_pct(self, pct_x, pct_y, pad=0): + """Pan by a percentage of the data space. This method is designed + to be called by scrollbar callbacks. + + Parameters + ---------- + pct_x : float (range 0.0 : 1.0) + Percentage in the X range to pan + + pct_y : float (range 0.0 : 1.0) + Percentage in the Y range to pan + + pad : int (optional, defaults to 0) + a padding amount in pixels to add to the limits when calculating - if not (set_min and set_max): - self.line_plot.ax.set_ylim(ymin, ymax) - if redraw: - self.line_plot.draw() + """ + # Sanity check on inputs + pct_x = np.clip(pct_x, 0.0, 1.0) + pct_y = np.clip(pct_y, 0.0, 1.0) + + limits = self.get_limits() + + mxwd, mxht = limits[1][:2] + mxwd, mxht = mxwd + pad, mxht + pad + mnwd, mnht = limits[0][:2] + mnwd, mnht = mnwd - pad, mnht - pad + + arr = np.array([(mnwd, mnht), (mxwd, mnht), + (mxwd, mxht), (mnwd, mxht)], + dtype=float) + x, y = arr.T + x_min, x_max = np.min(x), np.max(x) + y_min, y_max = np.min(y), np.max(y) + + crd_x = x_min + (pct_x * (x_max - x_min)) + crd_y = y_min + (pct_y * (y_max - y_min)) + + pan_x, pan_y = crd_x, crd_y + self.logger.debug("crd=%f,%f pan=%f,%f" % ( + crd_x, crd_y, pan_x, pan_y)) + + self.panset_xy(pan_x, pan_y) + + def zoom_plot(self, pct_x, pct_y, redraw=True): + """Zoom the plot, keeping the pan position.""" + set_range = False + (x_lo, x_hi), (y_lo, y_hi) = self.get_ranges() + pan_x, pan_y = self.get_pan()[:2] + + if pct_x is not None: + xrng = x_hi - x_lo + xinc = (xrng * 0.5) * pct_x + x_lo, x_hi = pan_x - xinc, pan_x + xinc + set_range = True + + if pct_y is not None: + yrng = y_hi - y_lo + yinc = (yrng * 0.5) * pct_y + y_lo, y_hi = pan_y - yinc, pan_y + yinc + set_range = True + + if set_range: + self.set_ranges(x_range=(x_lo, x_hi), y_range=(y_lo, y_hi)) + + def zoom_plot_at_cursor(self, cur_x, cur_y, pct_x, pct_y): + """Zoom the plot, keeping the position under the cursor.""" + set_range = False + (x_lo, x_hi), (y_lo, y_hi) = self.get_ranges() + + if pct_x is not None: + x_lo, x_hi = (cur_x - (cur_x - x_lo) * pct_x, + cur_x + (x_hi - cur_x) * pct_x) + set_range = True + + if pct_y is not None: + y_lo, y_hi = (cur_y - (cur_y - y_lo) * pct_y, + cur_y + (y_hi - cur_y) * pct_y) + set_range = True + + if set_range: + self.set_ranges(x_range=(x_lo, x_hi), y_range=(y_lo, y_hi)) + + def get_axes_size_in_px(self): + bbox = self.ax.get_window_extent().transformed(self.figure.dpi_scale_trans.inverted()) + width, height = bbox.width, bbox.height + width *= self.figure.dpi + height *= self.figure.dpi + return (width, height) + + def get_rgb_image_as_buffer(self, output=None, format='png', + quality=90): + """Get the current image shown in the viewer, with any overlaid + graphics, in a file IO-like object encoded as a bitmap graphics + file. + + This can be overridden by subclasses. + + Parameters + ---------- + output : a file IO-like object or None + open python IO descriptor or None to have one created + + format : str + A string defining the format to save the image. Typically + at least 'jpeg' and 'png' are supported. (default: 'png') + + quality: int + The quality metric for saving lossy compressed formats. + + Returns + ------- + buffer : file IO-like object + This will be the one passed in, unless `output` is None + in which case a BytesIO obejct is returned - def reset_xlim_cb(self): - self.line_plot.autoscale('x') + """ + obuf = output + if obuf is None: + obuf = BytesIO() - def reset_ylim_cb(self): - self.line_plot.autoscale('y') + fig_dpi = self.settings.get('plot_save_dpi', 100) + fig = self.get_figure() + fig.savefig(obuf, dpi=fig_dpi) - def set_marker_cb(self, w, val): - """Toggle show/hide data point markers.""" - self.do_plot() + return obuf - def save_cb(self): - """Save plot to file.""" + def save_rgb_image_as_file(self, filepath, format='png', quality=90): + """Save the current image shown in the viewer, with any overlaid + graphics, in a file with the specified format and quality. + This can be overridden by subclasses. - # This just defines the basename. - # Extension has to be explicitly defined or things can get messy. - w = Widgets.SaveDialog(title='Save plot') - target = w.get_path() - if target is None: - # Save canceled - return + Parameters + ---------- + filepath : str + path of the file to write - plot_ext = self.settings.get('file_suffix', '.png') + format : str + See :meth:`get_rgb_image_as_buffer`. - if not target.endswith(plot_ext): - target += plot_ext + quality: int + See :meth:`get_rgb_image_as_buffer`. - # TODO: This can be a user preference? - fig_dpi = 100 + """ + plot_ext = self.settings.get('plot_save_suffix', f'.{format}') + if not filepath.endswith(plot_ext): + filepath = filepath + plot_ext - try: - fig = self.line_plot.get_figure() - fig.savefig(target, dpi=fig_dpi) + fig_dpi = self.settings.get('plot_save_dpi', 100) + fig = self.get_figure() + fig.savefig(filepath, dpi=fig_dpi) - except Exception as e: - self.logger.error(str(e)) + def get_cursor(self, cname): + """Get the cursor stored under the name. + This can be overridden by subclasses, if necessary. + + Parameters + ---------- + cname : str + name of the cursor to return. + + """ + return self.cursor[cname] + + def define_cursor(self, cname, cursor): + """Define a viewer cursor under a name. Does not change the + current cursor. + + Parameters + ---------- + cname : str + name of the cursor to define. + + cursor : object + a cursor object in the back end's toolkit + + `cursor` is usually constructed from `make_cursor`. + """ + self.cursor[cname] = cursor + + def switch_cursor(self, cname): + """Switch the viewer's cursor to the one defined under a name. + + Parameters + ---------- + cname : str + name of the cursor to switch to. + + """ + cursor = self.get_cursor(cname) + self.figure.canvas.set_cursor(cursor) + + def __str__(self): + return "PlotViewBase" + + +class PlotViewEvent(Mixins.UIMixin, PlotViewBase): + + def __init__(self, logger=None, settings=None, figure=None): + PlotViewBase.__init__(self, logger=logger, settings=settings, + figure=figure) + Mixins.UIMixin.__init__(self) + + # for interactive features + self.can = Bunch.Bunch(zoom=False, pan=False) + + # for qt key handling + self._keytbl = { + 'shift': 'shift_l', + 'control': 'control_l', + 'alt': 'alt_l', + 'win': 'super_l', + 'meta': 'meta_right', + '`': 'backquote', + '"': 'doublequote', + "'": 'singlequote', + '\\': 'backslash', + ' ': 'space', + 'enter': 'return', + 'pageup': 'page_up', + 'pagedown': 'page_down', + } + # For callbacks + #for name in []: + # self.enable_callback(name) + + self.last_data_x, self.last_data_y = 0, 0 + self.connect_ui() + + # enable interactivity in the plot + self.__enable(zoom=True, pan=True) + + def __enable(self, pan=False, zoom=False): + # NOTE: don't use this interface! Likely to change!!! + """If `pan` is True, enable interactive panning in the plot by a + middle click. If `zoom` is True , enable interactive zooming in + the plot by scrolling. + """ + self.can.update(dict(pan=pan, zoom=zoom)) + self.ui_set_active(True, viewer=self) + if pan or zoom: + if pan: + self.add_callback('button-press', self.plot_do_pan) + if zoom: + self.add_callback('scroll', self.plot_do_zoom) + + def plot_do_zoom(self, cb_obj, event): + """Can be set as the callback function for the 'scroll' + event to zoom the plot. + """ + if not self.can.zoom: + return + + # Matplotlib only gives us the number of steps of the scroll, + # positive for up and negative for down. + if event.amount > 0: + delta = self.t_['plot_zoom_rate'] ** -2 + elif event.amount < 0: + delta = self.t_['plot_zoom_rate'] ** 2 + + delta_x = delta_y = delta + if 'ctrl' in event.modifiers: + # only horizontal + delta_y = 1.0 + elif 'shift' in event.modifiers: + # only horizontal + delta_x = 1.0 + + if 'meta' in event.modifiers: + # cursor position + cur_x, cur_y = event.data_x, event.data_y + if None not in [cur_x, cur_y]: + self.zoom_plot_at_cursor(cur_x, cur_y, delta_x, delta_y) else: - self.logger.info('Table plot saved as {0}'.format(target)) + self.zoom_plot(delta_x, delta_y) + + return True + + def plot_do_pan(self, cb_obj, event): + """Can be set as the callback function for the 'button-press' + event to pan the plot with middle-click. + """ + if event.button == 0x2: + if not self.can.pan: + return + cur_x, cur_y = event.data_x, event.data_y + if None not in [cur_x, cur_y]: + self.set_pan(cur_x, cur_y) + + return True + + def connect_ui(self): + canvas = self.figure.canvas + if canvas is None: + raise ValueError("matplotlib canvas is not yet created") + connect = canvas.mpl_connect + connect("motion_notify_event", self._plot_motion_notify) + connect("button_press_event", self._plot_button_press) + connect("button_release_event", self._plot_button_release) + connect("scroll_event", self._plot_scroll) + canvas.capture_scroll = True + connect("figure_enter_event", self._plot_enter_cursor) + connect("figure_leave_event", self._plot_leave_cursor) + connect("key_press_event", self._plot_key_press) + connect("key_release_event", self._plot_key_release) + connect("resize_event", self._plot_resize) + + # Define cursors + cursor_names = cursor_info.get_cursor_names() + # TODO: handle other cursor types + cross = Cursors.POINTER + for curname in cursor_names: + curinfo = cursor_info.get_cursor_info(curname) + self.define_cursor(curinfo.name, cross) + self.switch_cursor('pick') + + def __get_modifiers(self, event): + return event.modifiers + + def __get_button(self, event): + try: + btn = 0x1 << (event.button.value - 1) + except Exception: + btn = 0 + return btn + + def transkey(self, keyname): + self.logger.debug("matplotlib keyname='%s'" % (keyname)) + if keyname is None: + return keyname + key = keyname + if 'shift+' in key: + key = key.replace('shift+', '') + if 'ctrl+' in key: + key = key.replace('ctrl+', '') + if 'alt+' in key: + key = key.replace('alt+', '') + if 'meta+' in key: + key = key.replace('meta+', '') + return self._keytbl.get(key, key) + + def get_key_table(self): + return self._keytbl + + def __get_key(self, event): + keyval = self.transkey(event.key) + self.logger.debug("key event, mpl={}, key={}".format(event.key, + keyval)) + return keyval + + def _plot_scroll(self, event): + button = self.__get_button(event) + if event.button == 'up': + direction = 0.0 + elif event.button == 'down': + direction = 180.0 + amount = event.step + modifiers = self.__get_modifiers(event) + evt = events.ScrollEvent(viewer=self, button=button, state='scroll', + mode=None, modifiers=modifiers, + direction=direction, amount=amount, + data_x=event.xdata, data_y=event.ydata) + self.make_ui_callback('scroll', evt) + + def _plot_button_press(self, event): + button = self.__get_button(event) + modifiers = self.__get_modifiers(event) + self.last_data_x, self.last_data_y = event.xdata, event.ydata + evt = events.PointEvent(viewer=self, button=button, state='down', + mode=None, modifiers=modifiers, + data_x=event.xdata, data_y=event.ydata) + self.make_ui_callback('button-press', evt) + + def _plot_button_release(self, event): + button = self.__get_button(event) + modifiers = self.__get_modifiers(event) + self.last_data_x, self.last_data_y = event.xdata, event.ydata + evt = events.PointEvent(viewer=self, button=button, state='up', + mode=None, modifiers=modifiers, + data_x=event.xdata, data_y=event.ydata) + self.make_ui_callback('button-release', evt) + + def _plot_motion_notify(self, event): + button = self.__get_button(event) + modifiers = self.__get_modifiers(event) + self.last_data_x, self.last_data_y = event.xdata, event.ydata + evt = events.PointEvent(viewer=self, button=button, state='move', + mode=None, modifiers=modifiers, + data_x=event.xdata, data_y=event.ydata) + self.make_ui_callback('motion', evt) + + def _plot_key_press(self, event): + key = self.__get_key(event) + modifiers = self.__get_modifiers(event) + self.last_data_x, self.last_data_y = event.xdata, event.ydata + evt = events.KeyEvent(viewer=self, key=key, state='down', + mode=None, modifiers=modifiers, + data_x=event.xdata, data_y=event.ydata) + self.make_ui_callback('key-press', evt) + + def _plot_key_release(self, event): + key = self.__get_key(event) + modifiers = self.__get_modifiers(event) + self.last_data_x, self.last_data_y = event.xdata, event.ydata + evt = events.KeyEvent(viewer=self, key=key, state='up', + mode=None, modifiers=modifiers, + data_x=event.xdata, data_y=event.ydata) + self.make_ui_callback('key-release', evt) + + def _plot_resize(self, event): + wd, ht = event.width, event.height + self.make_callback('configure', wd, ht) + + def _plot_enter_cursor(self, event): + if self.t_['plot_enter_focus']: + w = self.get_widget() + w.setFocus() + + self.make_ui_callback('enter') + + def _plot_leave_cursor(self, event): + self.make_ui_callback('leave') def __str__(self): - return "PlotViewer" + return "PlotViewEvent" + + +PlotViewGw = PlotViewEvent diff --git a/ginga/gw/PluginManager.py b/ginga/gw/PluginManager.py index 4b597ae5e..59ee0d9f5 100644 --- a/ginga/gw/PluginManager.py +++ b/ginga/gw/PluginManager.py @@ -118,13 +118,16 @@ def get_info(self, name): lname = name.lower() return self.active[lname] - def activate(self, p_info, exclusive=True): + def activate(self, p_info, exclusive=None): name = p_info.tabname lname = p_info.name.lower() if lname in self.active: # plugin already active return + if exclusive is None: + exclusive = p_info.spec.get('exclusive', True) + bnch = Bunch.Bunch(pInfo=p_info, lblname=name, widget=None, exclusive=exclusive) @@ -310,7 +313,7 @@ def start_plugin_future(self, chname, opname, future, wd, ht = self.ds.get_ws_size(in_ws) vbox.extdata.size = (wd, ht) - if future: + if future is not None: p_info.obj.build_gui(vbox, future=future) else: p_info.obj.build_gui(vbox) diff --git a/ginga/icons/zoom_fit_x.svg b/ginga/icons/zoom_fit_x.svg new file mode 100644 index 000000000..44cba425e --- /dev/null +++ b/ginga/icons/zoom_fit_x.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + X + + + + diff --git a/ginga/icons/zoom_fit_y.svg b/ginga/icons/zoom_fit_y.svg new file mode 100644 index 000000000..7fd335b34 --- /dev/null +++ b/ginga/icons/zoom_fit_y.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + Y + + + diff --git a/ginga/mplw/ImageViewMpl.py b/ginga/mplw/ImageViewMpl.py index 0fcd8f7fd..4cbe1f76d 100644 --- a/ginga/mplw/ImageViewMpl.py +++ b/ginga/mplw/ImageViewMpl.py @@ -324,7 +324,6 @@ def __init__(self, logger=None, rgbmap=None, settings=None): ImageViewMpl.__init__(self, logger=logger, rgbmap=rgbmap, settings=settings) - # @$%&^(_)*&^ gnome!! self._keytbl = { 'shift': 'shift_l', 'control': 'control_l', @@ -404,16 +403,16 @@ def transkey(self, keyname): self.logger.debug("matplotlib keyname='%s'" % (keyname)) if keyname is None: return keyname - try: - key = keyname.lower() - if 'shift+' in key: - key = key.replace('shift+', '') - if 'ctrl+' in key: - key = key.replace('ctrl+', '') - return self._keytbl[key] - - except KeyError: - return keyname + key = keyname + if 'shift+' in key: + key = key.replace('shift+', '') + if 'ctrl+' in key: + key = key.replace('ctrl+', '') + if 'alt+' in key: + key = key.replace('alt+', '') + if 'meta+' in key: + key = key.replace('meta+', '') + return self._keytbl.get(key, key) def get_key_table(self): return self._keytbl diff --git a/ginga/plot/Plotable.py b/ginga/plot/Plotable.py new file mode 100644 index 000000000..323b460e5 --- /dev/null +++ b/ginga/plot/Plotable.py @@ -0,0 +1,97 @@ +# +# Plotable.py -- Abstraction of generic plot data. +# +# This is open-source software licensed under a BSD license. +# Please see the file LICENSE.txt for details. +# +import numpy as np + +from ginga.BaseImage import ViewerObjectBase, Header +from ginga.canvas.CanvasObject import get_canvas_types +from ginga.misc.Bunch import Bunch + + +class Plotable(ViewerObjectBase): + """Abstraction of a plot data. + + .. note:: This module is NOT thread-safe! + + """ + # class variables for WCS can be set + wcsClass = None + ioClass = None + + @classmethod + def set_wcsClass(cls, klass): + cls.wcsClass = klass + + @classmethod + def set_ioClass(cls, klass): + cls.ioClass = klass + + def __init__(self, data_ap=None, metadata=None, logger=None, name=None, + wcsclass=wcsClass, ioclass=ioClass): + + ViewerObjectBase.__init__(self, logger=logger, metadata=metadata, + name=name) + + self.wcs = None + self.io = None + + dc = get_canvas_types() + self.canvas = dc.Canvas() + self.titles = Bunch(title=None, x_axis=None, y_axis=None) + self.grid = False + self.rgb_order = 'RGBA' + + def get_size(self): + return (self.columns, self.rows) + + def get_canvas(self): + return self.canvas + + def get_header(self, create=True, include_primary_header=None): + # By convention, the header is stored in a dictionary + # under the metadata keyword 'header' + if 'header' not in self: + if not create: + # TODO: change to ValueError("No header found") + raise KeyError('header') + + hdr = Header() + self.set(header=hdr) + else: + hdr = self['header'] + + return hdr + + def has_primary_header(self): + return False + + def set_titles(self, title=None, x_axis=None, y_axis=None): + if x_axis is not None: + self.titles.x_axis = x_axis + if y_axis is not None: + self.titles.y_axis = y_axis + if title is not None: + self.titles.title = title + + def set_grid(self, tf): + self.grid = tf + + def clear(self): + self.titles.x_axis = None + self.titles.y_axis = None + self.titles.title = None + self.grid = False + + def get_minmax(self, noinf=False): + # TODO: what should this mean for a plot? + return (0, 0) + + def load_file(self, filespec): + raise NotImplementedError("This method is not yet implemented") + + def get_thumbnail(self, length): + thumb_np = np.eye(length) + return thumb_np diff --git a/ginga/qtw/ImageViewQt.py b/ginga/qtw/ImageViewQt.py index 75691212e..7d502b231 100644 --- a/ginga/qtw/ImageViewQt.py +++ b/ginga/qtw/ImageViewQt.py @@ -168,6 +168,7 @@ def __init__(self, logger=None, rgbmap=None, settings=None, render=None): ImageView.ImageViewBase.__init__(self, logger=logger, rgbmap=rgbmap, settings=settings) + self.needs_scrolledview = True if render is None: render = self.t_.get('render_widget', 'widget') self.wtype = render @@ -207,7 +208,7 @@ def __init__(self, logger=None, rgbmap=None, settings=None, render=None): self.msgtimer.add_callback('expired', lambda timer: self.onscreen_message_off()) - # For optomized redrawing + # For optimized redrawing self._defer_task = Timer() self._defer_task.add_callback('expired', lambda timer: self.delayed_redraw()) @@ -948,7 +949,7 @@ def __init__(self, viewer, parent=None): self._adjusting = False self._scrolling = False - self.pad = 20 + self.pad = 0 self.range_h = 10000 self.range_v = 10000 self.upper_h = self.range_h @@ -1035,6 +1036,7 @@ def scrollContentsBy(self, dx, dy): if res is None: return pct_x, pct_y = res.pan_pct_x, res.pan_pct_y + # pct_x, pct_y = None, None # Only adjust pan setting for axes that have changed if dx != 0: @@ -1055,6 +1057,11 @@ def scrollContentsBy(self, dx, dy): finally: self._scrolling = False + def wheelEvent(self, event): + # override because wheel events on viewport widget should not + # be processed by these scroll bars + event.accept() + def scroll_bars(self, horizontal='on', vertical='on'): self._bar_status.update(dict(horizontal=horizontal, vertical=vertical)) diff --git a/ginga/rv/Channel.py b/ginga/rv/Channel.py index c907d8263..1a9f6bb42 100644 --- a/ginga/rv/Channel.py +++ b/ginga/rv/Channel.py @@ -231,12 +231,13 @@ def add_image(self, image, silent=False, bulk_add=False): # No idx = image.get('idx', None) path = image.get('path', None) + nothumb = image.get('nothumb', False) image_loader = image.get('image_loader', None) image_future = image.get('image_future', None) info = self.add_history(imname, path, image_loader=image_loader, image_future=image_future, - idx=idx) + idx=idx, nothumb=nothumb) image.set(image_info=info) # add an image profile if one is missing @@ -459,7 +460,8 @@ def _add_info(self, info): return True def add_history(self, imname, path, idx=None, - image_loader=None, image_future=None): + image_loader=None, image_future=None, + nothumb=False): """Add metadata about a data object to this channel. See add_image_info() for use and additional information. @@ -485,6 +487,9 @@ def add_history(self, imname, path, idx=None, unloaded from memory. If `None` is passed, then defaults to a future using the default loader. + nothumb : bool (optional, default: False) + True if no thumbnail should be generated for this object. + Returns ------- info : `~ginga.Bunch.Bunch` @@ -511,7 +516,8 @@ def add_history(self, imname, path, idx=None, time_added=time.time(), time_modified=None, last_viewer_info=None, - profile=None) + profile=None, + nothumb=nothumb) self._add_info(info) else: @@ -600,16 +606,40 @@ def view_object(self, dataobj): self.fv.gui_choose_viewer(msg, viewers, self.open_with_viewer, dataobj) + def get_viewer(self, vname): + """Return the channel viewer with the specified name. + + Parameters + ---------- + vname : str + Name of the type of viewer. + + Returns + ------- + viewer : a ginga channel viewer of the specified type + + """ + # if we don't have this viewer type then install one in the channel + if vname not in self.viewer_dict: + vinfo = gviewer.get_vinfo(vname) + self.fv.make_viewer(vinfo, self) + + viewer = self.viewer_dict[vname] + return viewer + def open_with_viewer(self, vinfo, dataobj): # if we don't have this viewer type then install one in the channel if vinfo.name not in self.viewer_dict: self.fv.make_viewer(vinfo, self) - self.viewer = self.viewer_dict[vinfo.name] + old_viewer, self.viewer = self.viewer, self.viewer_dict[vinfo.name] # find this viewer and raise it idx = self.viewers.index(self.viewer) self.widget.set_index(idx) + self.fv.make_async_gui_callback('viewer-select', self, + old_viewer, self.viewer) + # and load the data if self.viewer.get_dataobj() is not dataobj: self.viewer.set_dataobj(dataobj) diff --git a/ginga/rv/Control.py b/ginga/rv/Control.py index 2dcc5ddbe..5cd48c5ee 100644 --- a/ginga/rv/Control.py +++ b/ginga/rv/Control.py @@ -103,7 +103,7 @@ def __init__(self, logger, thread_pool, module_manager, preferences, # For callbacks for name in ('add-image', 'channel-change', 'remove-image', 'add-channel', 'delete-channel', 'field-info', - 'add-image-info', 'remove-image-info'): + 'add-image-info', 'remove-image-info', 'viewer-select'): self.enable_callback(name) # Initialize the timer factory @@ -232,9 +232,9 @@ def make_gui_callback(self, name, *args, **kwargs): # PLUGIN MANAGEMENT def start_operation(self, opname): - return self.start_local_plugin(None, opname, None) + return self.start_local_plugin(None, opname) - def start_local_plugin(self, chname, opname, future): + def start_local_plugin(self, chname, opname, future=None): channel = self.get_channel(chname) opmon = channel.opmon opmon.start_plugin_future(channel.name, opname, future) @@ -1819,7 +1819,15 @@ def make_viewer(self, vinfo, channel): viewer = vinfo.vclass(logger=self.logger, settings=channel.settings) - stk_w.add_widget(viewer.get_widget(), title=vinfo.name) + if getattr(viewer, 'needs_scrolledview', False): + si = Viewers.ScrolledView(viewer) + scr_val = 'on' + si.scroll_bars(horizontal=scr_val, vertical=scr_val) + #scr_set.add_callback('set', self._toggle_scrollbars, si) + iw = Widgets.wrap(si) + stk_w.add_widget(iw, title=vinfo.name) + else: + stk_w.add_widget(viewer.get_widget(), title=vinfo.name) # let the GUI respond to this widget addition self.update_pending() @@ -2225,13 +2233,10 @@ def _select_viewer_cb(w, dct): box.add_widget(wgts.table, stretch=0) box.add_widget(wgts.descr, stretch=1) - ## if mimetype is not None: - ## hbox = Widgets.HBox() - ## wgts.choice = Widgets.CheckBox("Remember choice for session") - ## hbox.add_widget(wgts.choice) - ## box.add_widget(hbox, stretch=0) - ## else: - ## wgts.choice = None + hbox = Widgets.HBox() + wgts.choice = Widgets.CheckBox("Remember choice for session") + hbox.add_widget(wgts.choice) + box.add_widget(hbox, stretch=0) self.ds.show_dialog(dialog) dialog.resize(600, 600) @@ -2483,13 +2488,17 @@ def choose_viewer_cb(self, w, rsp, wgts, viewers, open_cb, dataobj): bnchs = list(sel_dct.values()) if len(bnchs) != 1: - # user didn't select an opener + # user didn't select a viewer self.show_error("Need to select one viewer!", raisetab=True) return bnch = bnchs[0] self.ds.remove_dialog(w) + if wgts.choice.get_state(): + # user wants us to remember their choice + bnch.priority = -99 + open_cb(bnch, dataobj) return True diff --git a/ginga/rv/main.py b/ginga/rv/main.py index 21a128464..44d03e971 100644 --- a/ginga/rv/main.py +++ b/ginga/rv/main.py @@ -80,15 +80,33 @@ Bunch(module='Operations', workspace='operations', start=True, hidden=True, category='System', menu="Operations [G]", ptype='global', enabled=True), - Bunch(module='Toolbar', workspace='toolbar', start=True, - hidden=True, category='System', menu="Toolbar [G]", ptype='global', + Bunch(module='Toolbar', klass='Toolbar', workspace='toolbar', + start=True, hidden=True, category='System', ptype='global', enabled=True), + Bunch(module='Toolbar', klass='Toolbar_Ginga_Image', + hidden=True, category='System', ptype='local', + enabled=True, exclusive=False), + Bunch(module='Toolbar', klass='Toolbar_Ginga_Plot', + hidden=True, category='System', ptype='local', + enabled=True, exclusive=False), + Bunch(module='Toolbar', klass='Toolbar_Ginga_Table', + hidden=True, category='System', ptype='local', + enabled=True, exclusive=False), Bunch(module='Pan', workspace='uleft', start=True, hidden=True, category='System', menu="Pan [G]", ptype='global', enabled=True), Bunch(module='Info', tab='Synopsis', workspace='lleft', start=True, hidden=True, category='System', menu="Info [G]", ptype='global', enabled=True), + Bunch(module='Info', klass='Info_Ginga_Image', + hidden=True, category='System', ptype='local', + enabled=True, exclusive=False), + Bunch(module='Info', klass='Info_Ginga_Plot', + hidden=True, category='System', ptype='local', + enabled=True, exclusive=False), + Bunch(module='Info', klass='Info_Ginga_Table', + hidden=True, category='System', ptype='local', + enabled=True, exclusive=False), Bunch(module='Thumbs', tab='Thumbs', workspace='right', start=True, hidden=True, category='System', menu="Thumbs [G]", ptype='global', enabled=True), diff --git a/ginga/rv/plugins/Cursor.py b/ginga/rv/plugins/Cursor.py index c6c20bb88..399fced40 100644 --- a/ginga/rv/plugins/Cursor.py +++ b/ginga/rv/plugins/Cursor.py @@ -146,8 +146,12 @@ def field_info_cb(self, viewer, channel, info): value = info.value # Update the readout - px_x = "%.3f" % info.x - px_y = "%.3f" % info.y + try: + px_x = "%.3f" % info.x + px_y = "%.3f" % info.y + except Exception: + px_x = "None" + px_y = "None" maxx = max(readout.maxx, len(str(px_x))) if maxx > readout.maxx: readout.maxx = maxx diff --git a/ginga/rv/plugins/Info.py b/ginga/rv/plugins/Info.py index cdde49e36..da90d8593 100644 --- a/ginga/rv/plugins/Info.py +++ b/ginga/rv/plugins/Info.py @@ -46,74 +46,72 @@ file--then Close and Help buttons will be added to the bottom of the UI. """ +import time +import numpy as np + from ginga.gw import Widgets -from ginga.misc import Bunch from ginga import GingaPlugin, ColorDist +from ginga.rv.plugins.Toolbar import Toolbar +from ginga.table.AstroTable import AstroTable +from ginga.plot.Plotable import Plotable +from ginga.canvas.CanvasObject import get_canvas_types -__all__ = ['Info'] +__all__ = ['Info', 'Info_Ginga_Image', + 'Info_Ginga_Plot', 'Info_Ginga_Table'] -class Info(GingaPlugin.GlobalPlugin): +class Info(Toolbar): def __init__(self, fv): # superclass defines some variables for us, like logger - super(Info, self).__init__(fv) + super().__init__(fv) + + self.opname_prefix = 'Info_' + + def __str__(self): + return 'info' + + +class Info_Common(GingaPlugin.LocalPlugin): + + def __init__(self, fv, chviewer): + # superclass defines some variables for us, like logger + super().__init__(fv, chviewer) - self.active = None - self.info = None # truncate names after this size self.maxstr = 60 - spec = self.fv.get_plugin_spec(str(self)) + def trunc(self, s): + if len(s) > self.maxstr: + return s[:self.maxstr - 3] + '...' + else: + return s - prefs = self.fv.get_preferences() - self.settings = prefs.create_category('plugin_Info') - self.settings.add_defaults(closeable=not spec.get('hidden', False)) - self.settings.load(onError='silent') + +class Info_Ginga_Image(Info_Common): + """Info sidebar for the Ginga Image viewer. + """ + + def __init__(self, fv, chviewer): + # superclass defines some variables for us, like logger + super().__init__(fv, chviewer) self.autozoom_options = ['on', 'override', 'once', 'off'] self.autocut_options = self.autozoom_options self.autocenter_options = self.autozoom_options - fv.add_callback('add-channel', self.add_channel) - fv.add_callback('delete-channel', self.delete_channel) - fv.add_callback('field-info', self.field_info) - fv.add_callback('channel-change', self.focus_cb) + self.fv.add_callback('field-info', self.field_info_cb) + self.gui_up = False def build_gui(self, container): - vbox = Widgets.VBox() - vbox.set_border_width(2) - vbox.set_spacing(2) - - nb = Widgets.StackWidget() - self.nb = nb - vbox.add_widget(nb, stretch=1) - - if self.settings.get('closeable', False): - btns = Widgets.HBox() - btns.set_border_width(4) - btns.set_spacing(4) - - btn = Widgets.Button("Close") - btn.add_callback('activated', lambda w: self.close()) - btns.add_widget(btn) - btn = Widgets.Button("Help") - btn.add_callback('activated', lambda w: self.help()) - btns.add_widget(btn, stretch=0) - btns.add_widget(Widgets.Label(''), stretch=1) - vbox.add_widget(btns, stretch=0) - - container.add_widget(vbox, stretch=1) - self.gui_up = True - - def _create_info_window(self, channel): sw = Widgets.ScrollArea() vbox = Widgets.VBox() sw.set_widget(vbox) - captions = (('Name:', 'label', 'Name', 'llabel'), + captions = (('Channel:', 'label', 'Channel', 'llabel'), + ('Name:', 'label', 'Name', 'llabel'), ('Object:', 'label', 'Object', 'llabel'), ('X:', 'label', 'X', 'llabel'), ('Y:', 'label', 'Y', 'llabel'), @@ -128,6 +126,7 @@ def _create_info_window(self, channel): ('Max:', 'label', 'Max', 'llabel'), ) w, b = Widgets.build_info(captions) + self.w = b col = Widgets.VBox() row = Widgets.HBox() @@ -141,8 +140,7 @@ def _create_info_window(self, channel): sw2.set_widget(col) vbox.add_widget(sw2, stretch=1) - captions = (('Channel:', 'label', 'Channel', 'llabel'), - ('Zoom:', 'label', 'Zoom', 'llabel', + captions = (('Zoom:', 'label', 'Zoom', 'llabel', 'Color Dist', 'combobox'), ('Cut Low:', 'label', 'Cut Low Value', 'llabel', 'Cut Low', 'entry'), @@ -157,10 +155,10 @@ def _create_info_window(self, channel): 'Raise New', 'checkbutton'), ) - w, b2 = Widgets.build_info(captions) - b.update(b2) + w, b = Widgets.build_info(captions) + self.w.update(b) b.cut_levels.set_tooltip("Set cut levels manually") - loval, hival = channel.fitsimage.get_cut_levels() + loval, hival = self.fitsimage.get_cut_levels() b.auto_levels.set_tooltip("Set cut levels by algorithm") b.cut_low.set_tooltip("Set low cut level (press Enter)") b.cut_low.set_length(9) @@ -201,144 +199,60 @@ def _create_info_window(self, channel): row.add_widget(Widgets.Label(''), stretch=1) vbox.add_widget(row, stretch=0) - return sw, b - - def add_channel(self, viewer, channel): - if not self.gui_up: - return - sw, winfo = self._create_info_window(channel) - chname = channel.name - - self.nb.add_widget(sw, title=chname) - index = self.nb.index_of(sw) # noqa - info = Bunch.Bunch(widget=sw, winfo=winfo, - mode_w=None, - chinfo=channel) - channel.extdata._info_info = info - - winfo.channel.set_text(chname) - winfo.color_dist.add_callback('activated', self.set_color_dist, - channel, info) - winfo.cut_low.add_callback('activated', self.cut_levels, - channel, info) - winfo.cut_high.add_callback('activated', self.cut_levels, - channel, info) - winfo.cut_levels.add_callback('activated', self.cut_levels, - channel, info) - winfo.auto_levels.add_callback('activated', self.auto_levels, - channel, info) - winfo.cut_new.add_callback('activated', self.set_autocuts_cb, - channel, info) - winfo.zoom_new.add_callback('activated', self.set_autozoom_cb, - channel, info) - winfo.center_new.add_callback('activated', self.set_autocenter_cb, - channel, info) - winfo.follow_new.add_callback('activated', self.set_follow_cb, - channel, info) - winfo.raise_new.add_callback('activated', self.set_raise_cb, - channel, info) - - fitsimage = channel.fitsimage - fitssettings = fitsimage.get_settings() + self.w.channel.set_text(self.channel.name) + self.w.color_dist.add_callback('activated', self.set_color_dist) + self.w.cut_low.add_callback('activated', self.cut_levels) + self.w.cut_high.add_callback('activated', self.cut_levels) + self.w.cut_levels.add_callback('activated', self.cut_levels) + self.w.auto_levels.add_callback('activated', self.auto_levels) + self.w.cut_new.add_callback('activated', self.set_autocuts_cb) + self.w.zoom_new.add_callback('activated', self.set_autozoom_cb) + self.w.center_new.add_callback('activated', self.set_autocenter_cb) + self.w.follow_new.add_callback('activated', self.set_follow_cb) + self.w.raise_new.add_callback('activated', self.set_raise_cb) + + fitssettings = self.fitsimage.get_settings() for name in ['cuts']: - fitssettings.get_setting(name).add_callback( - 'set', self.cutset_cb, channel) + fitssettings.get_setting(name).add_callback('set', + self.cutset_cb) for name in ['scale']: - fitssettings.get_setting(name).add_callback( - 'set', self.zoomset_cb, channel) - fitssettings.get_setting('color_algorithm').add_callback( - 'set', self.cdistset_cb, channel) - fitssettings.get_setting('autocuts').add_callback( - 'set', self.autocuts_cb, channel) - fitssettings.get_setting('autozoom').add_callback( - 'set', self.autozoom_cb, channel) - fitssettings.get_setting('autocenter').add_callback( - 'set', self.autocenter_cb, channel) - fitssettings.get_setting('switchnew').add_callback( - 'set', self.follow_cb, channel) - fitssettings.get_setting('raisenew').add_callback( - 'set', self.raise_cb, channel) - - self.set_info(info, fitsimage) - - def delete_channel(self, viewer, channel): - if not self.gui_up: - return - chname = channel.name - self.logger.debug("deleting channel %s" % (chname)) - info = channel.extdata._info_info - channel.extdata._info_info = None - widget = info.widget - self.nb.remove(widget, delete=True) - self.active = None - self.info = None + fitssettings.get_setting(name).add_callback('set', + self.zoomset_cb) + fitssettings.get_setting('color_algorithm').add_callback('set', + self.cdistset_cb) + fitssettings.get_setting('autocuts').add_callback('set', + self.autocuts_cb) + fitssettings.get_setting('autozoom').add_callback('set', + self.autozoom_cb) + fitssettings.get_setting('autocenter').add_callback('set', + self.autocenter_cb) + fitssettings.get_setting('switchnew').add_callback('set', + self.follow_cb) + fitssettings.get_setting('raisenew').add_callback('set', + self.raise_cb) + + container.add_widget(sw, stretch=1) + self.gui_up = True def start(self): - names = self.fv.get_channel_names() - for name in names: - channel = self.fv.get_channel(name) - self.add_channel(self.fv, channel) - - channel = self.fv.get_channel_info() - if channel is not None: - viewer = channel.fitsimage - - image = viewer.get_image() - if image is not None: - self.redo(channel, image) - - self.focus_cb(viewer, channel) + self.redo() def stop(self): - names = self.fv.get_channel_names() - for name in names: - channel = self.fv.get_channel(name) - channel.extdata._info_info = None - - self.active = None - self.nb = None - self.info = None self.gui_up = False def close(self): - self.fv.stop_global_plugin(str(self)) - return True + # NOTE: this shouldn't be called under normal usage + self.fv.stop_local_plugin(self.chname, str(self)) - def redo(self, channel, image): - if not self.gui_up: - return - fitsimage = channel.fitsimage - info = channel.extdata._info_info + def redo(self): + self.set_info() - self.set_info(info, fitsimage) - return True - - def focus_cb(self, viewer, channel): - if not self.gui_up: - return - chname = channel.name - - if self.active != chname: - if '_info_info' not in channel.extdata: - self.add_channel(viewer, channel) - info = channel.extdata._info_info - widget = info.widget - index = self.nb.index_of(widget) - self.nb.set_index(index) - self.active = chname - self.info = info - - self.set_info(self.info, channel.fitsimage) - - def zoomset_cb(self, setting, value, channel): + def zoomset_cb(self, setting, value): """This callback is called when the main window is zoomed. """ if not self.gui_up: return - info = channel.extdata._info_info - if info is None: - return - #scale_x, scale_y = fitsimage.get_scale_xy() + #scale_x, scale_y = self.fitsimage.get_scale_xy() scale_x, scale_y = value # Set text showing zoom factor (1X, 2X, etc.) @@ -348,221 +262,708 @@ def zoomset_cb(self, setting, value, channel): textx = self.fv.scale2text(scale_x) texty = self.fv.scale2text(scale_y) text = "X: %s Y: %s" % (textx, texty) - info.winfo.zoom.set_text(text) + self.w.zoom.set_text(text) - def cutset_cb(self, setting, value, channel): + def cutset_cb(self, setting, value): if not self.gui_up: return - info = channel.extdata._info_info - if info is None: - return loval, hival = value - #info.winfo.cut_low.set_text('%.4g' % (loval)) - info.winfo.cut_low_value.set_text('%.4g' % (loval)) - #info.winfo.cut_high.set_text('%.4g' % (hival)) - info.winfo.cut_high_value.set_text('%.4g' % (hival)) + #self.w.cut_low.set_text('%.4g' % (loval)) + self.w.cut_low_value.set_text('%.4g' % (loval)) + #self.w.cut_high.set_text('%.4g' % (hival)) + self.w.cut_high_value.set_text('%.4g' % (hival)) - def cdistset_cb(self, setting, value, channel): + def cdistset_cb(self, setting, value): if not self.gui_up: return - info = channel.extdata._info_info - if info is None: - return name = value - info.winfo.color_dist.set_text(name) + self.w.color_dist.set_text(name) - def autocuts_cb(self, setting, option, channel): + def autocuts_cb(self, setting, option): if not self.gui_up: return - info = channel.extdata._info_info - if info is None: - return self.logger.debug("autocuts changed to %s" % option) index = self.autocut_options.index(option) - info.winfo.cut_new.set_index(index) + self.w.cut_new.set_index(index) - def autozoom_cb(self, setting, option, channel): + def autozoom_cb(self, setting, option): if not self.gui_up: return - info = channel.extdata._info_info - if info is None: - return index = self.autozoom_options.index(option) - info.winfo.zoom_new.set_index(index) + self.w.zoom_new.set_index(index) - def autocenter_cb(self, setting, option, channel): + def autocenter_cb(self, setting, option): if not self.gui_up: return - info = channel.extdata._info_info - if info is None: - return # Hack to convert old values that used to be T/F if isinstance(option, bool): choice = {True: 'on', False: 'off'} option = choice[option] index = self.autocenter_options.index(option) - info.winfo.center_new.set_index(index) + self.w.center_new.set_index(index) - def follow_cb(self, setting, option, channel): + def follow_cb(self, setting, option): if not self.gui_up: return - info = channel.extdata._info_info - if info is None: - return - info.winfo.follow_new.set_state(option) + self.w.follow_new.set_state(option) - def raise_cb(self, setting, option, channel): + def raise_cb(self, setting, option): if not self.gui_up: return - info = channel.extdata._info_info - if info is None: - return - info.winfo.raise_new.set_state(option) + self.w.raise_new.set_state(option) - def set_autocuts_cb(self, w, index, channel, info): + def set_autocuts_cb(self, w, index): if not self.gui_up: return option = self.autocut_options[index] - channel.fitsimage.enable_autocuts(option) + self.fitsimage.enable_autocuts(option) - def set_autozoom_cb(self, w, index, channel, info): + def set_autozoom_cb(self, w, index): if not self.gui_up: return option = self.autozoom_options[index] - channel.fitsimage.enable_autozoom(option) + self.fitsimage.enable_autozoom(option) - def set_autocenter_cb(self, w, index, channel, info): + def set_autocenter_cb(self, w, index): if not self.gui_up: return option = self.autocenter_options[index] - channel.fitsimage.enable_autocenter(option) + self.fitsimage.enable_autocenter(option) - def set_follow_cb(self, w, tf, channel, info): + def set_follow_cb(self, w, tf): if not self.gui_up: return - channel.fitsimage.get_settings().set(switchnew=tf) + self.fitsimage.get_settings().set(switchnew=tf) - def set_raise_cb(self, w, tf, channel, info): + def set_raise_cb(self, w, tf): if not self.gui_up: return - channel.fitsimage.get_settings().set(raisenew=tf) + self.fitsimage.get_settings().set(raisenew=tf) # LOGIC - def trunc(self, s): - if len(s) > self.maxstr: - return s[:self.maxstr - 3] + '...' - else: - return s - - def set_info(self, info, fitsimage): + def set_info(self): + if not self.gui_up: + return # Show cut levels - loval, hival = fitsimage.get_cut_levels() - #info.winfo.cut_low.set_text('%.4g' % (loval)) - info.winfo.cut_low_value.set_text('%.4g' % (loval)) - #info.winfo.cut_high.set_text('%.4g' % (hival)) - info.winfo.cut_high_value.set_text('%.4g' % (hival)) + loval, hival = self.fitsimage.get_cut_levels() + #self.w.cut_low.set_text('%.4g' % (loval)) + self.w.cut_low_value.set_text('%.4g' % (loval)) + #self.w.cut_high.set_text('%.4g' % (hival)) + self.w.cut_high_value.set_text('%.4g' % (hival)) # update zoom indicator - scalefactor = fitsimage.get_scale() + scalefactor = self.fitsimage.get_scale() text = self.fv.scale2text(scalefactor) - info.winfo.zoom.set_text(text) + self.w.zoom.set_text(text) # update cut new/zoom new indicators - t_ = fitsimage.get_settings() + t_ = self.fitsimage.get_settings() index = self.autocut_options.index(t_['autocuts']) - info.winfo.cut_new.set_index(index) + self.w.cut_new.set_index(index) index = self.autozoom_options.index(t_['autozoom']) - info.winfo.zoom_new.set_index(index) + self.w.zoom_new.set_index(index) option = t_['autocenter'] # Hack to convert old values that used to be T/F if isinstance(option, bool): choice = {True: 'on', False: 'off'} option = choice[option] index = self.autocenter_options.index(option) - info.winfo.center_new.set_index(index) - info.winfo.follow_new.set_state(t_['switchnew']) - info.winfo.raise_new.set_state(t_['raisenew']) + self.w.center_new.set_index(index) + self.w.follow_new.set_state(t_['switchnew']) + self.w.raise_new.set_state(t_['raisenew']) # Set color distribution indicator name = t_['color_algorithm'] - info.winfo.color_dist.set_text(name) + self.w.color_dist.set_text(name) - image = fitsimage.get_image() + image = self.fitsimage.get_image() if image is None: return header = image.get_header() # Update info panel name = self.trunc(image.get('name', 'Noname')) - info.winfo.name.set_text(name) + self.w.name.set_text(name) objtext = self.trunc(header.get('OBJECT', 'UNKNOWN')) - info.winfo.object.set_text(objtext) + self.w.object.set_text(objtext) equinox = header.get('EQUINOX', '') - info.winfo.equinox.set_text(str(equinox)) + self.w.equinox.set_text(str(equinox)) # Show min, max values - width, height = fitsimage.get_data_size() + width, height = self.fitsimage.get_data_size() minval, maxval = image.get_minmax(noinf=False) - info.winfo.max.set_text(str(maxval)) - info.winfo.min.set_text(str(minval)) + self.w.max.set_text(str(maxval)) + self.w.min.set_text(str(minval)) # Show dimensions dim_txt = "%dx%d" % (width, height) - info.winfo.dimensions.set_text(dim_txt) + self.w.dimensions.set_text(dim_txt) - def field_info(self, viewer, channel, info): - if not self.gui_up or channel is None: - return - if '_info_info' not in channel.extdata: + def field_info_cb(self, fv, channel, info): + if not self.gui_up: return - obj = channel.extdata._info_info - - obj.winfo.x.set_text("%.3f" % info.x) - obj.winfo.y.set_text("%.3f" % info.y) + self.w.x.set_text("%.3f" % info.x) + self.w.y.set_text("%.3f" % info.y) if 'image_x' in info: - obj.winfo.image_x.set_text("%.3f" % info.image_x) + self.w.image_x.set_text("%.3f" % info.image_x) else: - obj.winfo.image_x.set_text("") + self.w.image_x.set_text("") if 'image_y' in info: - obj.winfo.image_y.set_text("%.3f" % info.image_y) + self.w.image_y.set_text("%.3f" % info.image_y) else: - obj.winfo.image_y.set_text("") - obj.winfo.value.set_text(str(info.value)) + self.w.image_y.set_text("") + self.w.value.set_text(str(info.value)) if 'ra_txt' in info: - obj.winfo.ra.set_text(info.ra_txt) - obj.winfo.dec.set_text(info.dec_txt) + self.w.ra.set_text(info.ra_txt) + self.w.dec.set_text(info.dec_txt) if 'ra_lbl' in info: - obj.winfo.lbl_ra.set_text(info.ra_lbl + ':') - obj.winfo.lbl_dec.set_text(info.dec_lbl + ':') + self.w.lbl_ra.set_text(info.ra_lbl + ':') + self.w.lbl_dec.set_text(info.dec_lbl + ':') - def cut_levels(self, w, channel, info): - fitsimage = channel.fitsimage - loval, hival = fitsimage.get_cut_levels() + def cut_levels(self, w): + loval, hival = self.fitsimage.get_cut_levels() try: - lostr = info.winfo.cut_low.get_text().strip() + lostr = self.w.cut_low.get_text().strip() if lostr != '': loval = float(lostr) - histr = info.winfo.cut_high.get_text().strip() + histr = self.w.cut_high.get_text().strip() if histr != '': hival = float(histr) self.logger.debug("locut=%f hicut=%f" % (loval, hival)) - return fitsimage.cut_levels(loval, hival) + return self.fitsimage.cut_levels(loval, hival) except Exception as e: self.fv.show_error("Error cutting levels: %s" % (str(e))) return True - def auto_levels(self, w, channel, info): - channel.fitsimage.auto_levels() + def auto_levels(self, w): + self.fitsimage.auto_levels() - def set_color_dist(self, w, idx, channel, info): + def set_color_dist(self, w, idx): name = w.get_text() - channel.fitsimage.set_color_algorithm(name) + self.fitsimage.set_color_algorithm(name) def __str__(self): - return 'info' + return 'info_ginga_image' + + +class Info_Ginga_Plot(Info_Common): + """Info sidebar for the Ginga Plot viewer. + """ + + def __init__(self, fv, fitsimage): + # superclass defines some variables for us, like logger + super().__init__(fv, fitsimage) + + # Plugin preferences + prefs = self.fv.get_preferences() + self.settings = prefs.create_category('info_Ginga_Plot') + self.settings.add_defaults(linewidth=1, + linestyle='-', + linecolor='blue', + markersize=6, + markerwidth=0.5, + markerstyle='o', + markercolor='red', + show_marker=False, + file_suffix='.png') + self.settings.load(onError='silent') + + viewer = self.channel.get_viewer('Ginga Plot') + self.plot_viewer = viewer + + viewer.add_callback('range-set', self.range_set_cb) + viewer.add_callback('motion', self.motion_cb) + + self.gui_up = False + + def build_gui(self, container): + # if not have_mpl: + # raise ImportError('Install matplotlib to use this plugin') + t_ = self.plot_viewer.get_settings() + sw = Widgets.ScrollArea() + + vbox = Widgets.VBox() + sw.set_widget(vbox) + + captions = (('Channel:', 'label', 'channel', 'llabel'), + ('Name:', 'label', 'name', 'llabel'), + ('X:', 'label', 'x_val', 'llabel'), + ('Y:', 'label', 'y_val', 'llabel'), + ) + w, b = Widgets.build_info(captions) + self.w = b + + self.w.channel.set_text(self.channel.name) + + col = Widgets.VBox() + row = Widgets.HBox() + row.set_spacing(0) + row.set_border_width(0) + row.add_widget(w, stretch=0) + row.add_widget(Widgets.Label(''), stretch=1) + col.add_widget(row, stretch=0) + col.add_widget(Widgets.Label(''), stretch=1) + sw2 = Widgets.ScrollArea() + sw2.set_widget(col) + vbox.add_widget(sw2, stretch=1) + + captions = (('spacer_1', 'spacer', 'X', 'llabel', 'Y', 'llabel'), + ('Dist:', 'label', 'x_dist', 'combobox', + 'y_dist', 'combobox'), + ('Low:', 'label', 'x_lo', 'entry', 'y_lo', 'entry'), + ('High:', 'label', 'x_hi', 'entry', 'y_hi', 'entry'), + ('spacer_3', 'spacer', 'Show marker', 'checkbox', + 'Save Plot', 'button'), + ) + w, b = Widgets.build_info(captions) + self.w.update(b) + + # Controls for X-axis scaling + combobox = b.x_dist + for name in ['linear', 'log']: + combobox.append_text(name) + combobox.set_tooltip('Select a mapping to plot on X-axis') + combobox.set_text(t_['plot_dist_axis'][0]) + combobox.add_callback('activated', self.x_dist_cb) + + # Controls for Y-axis column listing + combobox = b.y_dist + for name in ['linear', 'log']: + combobox.append_text(name) + combobox.set_tooltip('Select a mapping to plot on Y-axis') + combobox.set_text(t_['plot_dist_axis'][1]) + combobox.add_callback('activated', self.y_dist_cb) + + self.set_xlimits_widgets() + self.set_ylimits_widgets() + + b.x_lo.add_callback('activated', lambda w: self.set_xlim_lo_cb()) + b.x_lo.set_tooltip('Set X lower limit') + + b.x_hi.add_callback('activated', lambda w: self.set_xlim_hi_cb()) + b.x_hi.set_tooltip('Set X upper limit') + + b.y_lo.add_callback('activated', lambda w: self.set_ylim_lo_cb()) + b.y_lo.set_tooltip('Set Y lower limit') + + b.y_hi.add_callback('activated', lambda w: self.set_ylim_hi_cb()) + b.y_hi.set_tooltip('Set Y upper limit') + + b.show_marker.set_state(t_.get('plot_show_marker', False)) + b.show_marker.add_callback('activated', self.set_marker_cb) + b.show_marker.set_tooltip('Mark data points') + + # Button to save plot + b.save_plot.set_tooltip("Save plot to file") + b.save_plot.add_callback('activated', lambda w: self.save_cb()) + b.save_plot.set_enabled(True) + + row = Widgets.HBox() + row.set_spacing(0) + row.set_border_width(0) + row.add_widget(w, stretch=0) + row.add_widget(Widgets.Label(''), stretch=1) + vbox.add_widget(row, stretch=0) + + container.add_widget(sw, stretch=1) + self.gui_up = True + + def redo(self): + """This is called when a new image arrives or the data in the + existing image changes. + """ + if not self.gui_up: + return + + dataobj = self.plot_viewer.get_dataobj() + + name = self.trunc(dataobj.get('name', 'Noname')) + self.w.name.set_text(name) + + def set_xlimits_widgets(self, set_min=True, set_max=True): + """Populate axis limits GUI with current plot values.""" + ranges = self.plot_viewer.get_ranges() + (x_lo, x_hi), (y_lo, y_hi) = ranges + if set_min: + self.w.x_lo.set_text('{0}'.format(x_lo)) + if set_max: + self.w.x_hi.set_text('{0}'.format(x_hi)) + + def set_ylimits_widgets(self, set_min=True, set_max=True): + """Populate axis limits GUI with current plot values.""" + ranges = self.plot_viewer.get_ranges() + (x_lo, x_hi), (y_lo, y_hi) = ranges + if set_min: + self.w.y_lo.set_text('{0}'.format(y_lo)) + if set_max: + self.w.y_hi.set_text('{0}'.format(y_hi)) + + def x_dist_cb(self, w, index): + _dist = w.get_text().lower() + self.plot_viewer.set_dist_axis(x_axis=_dist) + + def y_dist_cb(self, w, index): + _dist = w.get_text().lower() + self.plot_viewer.set_dist_axis(y_axis=_dist) + + def set_xlim_lo_cb(self, redraw=True): + """Set plot limit based on user values.""" + limits = self.plot_viewer.get_limits() + (x_lo, y_lo), (x_hi, y_hi) = limits + + try: + x_min = float(self.w.x_lo.get_text()) + except Exception as e: + self.fv.show_error(f"error setting X low limit: {e}") + return + + self.plot_viewer.set_ranges(x_range=(x_min, x_hi)) + + def set_xlim_hi_cb(self, redraw=True): + """Set plot limit based on user values.""" + limits = self.plot_viewer.get_limits() + (x_lo, y_lo), (x_hi, y_hi) = limits + + try: + x_max = float(self.w.x_hi.get_text()) + except Exception as e: + self.fv.show_error(f"error setting X high limit: {e}") + return + + self.plot_viewer.set_ranges(x_range=(x_lo, x_max)) + + def set_ylim_lo_cb(self, redraw=True): + """Set plot limit based on user values.""" + limits = self.plot_viewer.get_limits() + (x_lo, y_lo), (x_hi, y_hi) = limits + + try: + y_min = float(self.w.y_lo.get_text()) + except Exception as e: + self.fv.show_error(f"error setting Y low limit: {e}") + return + + self.plot_viewer.set_ranges(y_range=(y_min, y_hi)) + + def set_ylim_hi_cb(self, redraw=True): + """Set plot limit based on user values.""" + limits = self.plot_viewer.get_limits() + (x_lo, y_lo), (x_hi, y_hi) = limits + + try: + y_max = float(self.w.y_hi.get_text()) + except Exception as e: + self.fv.show_error(f"error setting Y high limit: {e}") + return + + self.plot_viewer.set_ranges(y_range=(y_lo, y_max)) + + def range_set_cb(self, viewer, ranges): + (xmin, xmax), (ymin, ymax) = ranges + + if self.gui_up: + self.set_xlimits_widgets() + self.set_ylimits_widgets() + + def motion_cb(self, viewer, event): + if not self.gui_up: + return + data_x, data_y = event.data_x, event.data_y + x_str = str(data_x) + y_str = str(data_y) + self.w.x_val.set_text(x_str) + self.w.y_val.set_text(y_str) + + def set_marker_cb(self, w, tf): + """Toggle show/hide data point markers.""" + settings = self.plot_viewer.get_settings() + settings.set(plot_show_marker=tf) + + def save_cb(self): + """Save plot to file.""" + + # This just defines the basename. + # Extension has to be explicitly defined or things can get messy. + w = Widgets.SaveDialog(title='Save plot') + target = w.get_path() + if target is None: + # Save canceled + return + + plot_ext = self.settings.get('file_suffix', '.png') + + try: + self.plot_viewer.save_rgb_image_as_file(target, format='png') + + except Exception as e: + self.logger.error(str(e)) + else: + self.logger.info('Table plot saved as {0}'.format(target)) + + def start(self): + self.redo() + + def stop(self): + self.gui_up = False + + def close(self): + # NOTE: this shouldn't be called under normal usage + self.fv.stop_local_plugin(self.chname, str(self)) + + def __str__(self): + return 'info_ginga_plot' + + +class Info_Ginga_Table(Info_Common): + """Info sidebar for the Ginga Table viewer. + """ + + def __init__(self, fv, chviewer): + # superclass defines some variables for us, like logger + super().__init__(fv, chviewer) + + viewer = self.channel.get_viewer('Ginga Table') + self.table_viewer = viewer + + # To store all active table info + self.tab = None + self.cols = [] + self._idx = [] + self._idxname = '_idx' + # To store selected columns names of active table + self.x_col = '' + self.y_col = '' + + self.gui_up = False + + def build_gui(self, container): + sw = Widgets.ScrollArea() + + vbox = Widgets.VBox() + sw.set_widget(vbox) + + captions = (('Channel:', 'label', 'channel', 'llabel'), + ('Name:', 'label', 'name', 'llabel'), + ) + w, b = Widgets.build_info(captions) + self.w = b + + self.w.channel.set_text(self.channel.name) + + col = Widgets.VBox() + row = Widgets.HBox() + row.set_spacing(0) + row.set_border_width(0) + row.add_widget(w, stretch=0) + row.add_widget(Widgets.Label(''), stretch=1) + col.add_widget(row, stretch=0) + col.add_widget(Widgets.Label(''), stretch=1) + sw2 = Widgets.ScrollArea() + sw2.set_widget(col) + vbox.add_widget(sw2, stretch=1) + + channel = self.channel + self.w.channel.set_text(channel.name) + + captions = (('spacer_1', 'spacer', 'X', 'llabel', 'Y', 'llabel'), + ('Col:', 'label', 'x_col', 'combobox', 'y_col', 'combobox'), + ('spacer_3', 'spacer', 'spacer_4', 'spacer', + 'Make Plot', 'button'), + ) + w, b = Widgets.build_info(captions) + self.w.update(b) + + # Controls for X-axis column listing + combobox = b.x_col + combobox.set_enabled(False) + for name in self.cols: + combobox.append_text(name) + if self.x_col in self.cols: + combobox.set_text(self.x_col) + combobox.add_callback('activated', self.x_select_cb) + combobox.set_tooltip('Select a column to plot on X-axis') + + # Controls for Y-axis column listing + combobox = b.y_col + combobox.set_enabled(False) + for name in self.cols: + combobox.append_text(name) + if self.y_col in self.cols: + combobox.set_text(self.y_col) + combobox.add_callback('activated', self.y_select_cb) + combobox.set_tooltip('Select a column to plot on Y-axis') + + # Button to save plot + b.make_plot.set_tooltip("Plot selected columns") + b.make_plot.add_callback('activated', self.make_plot_cb) + b.make_plot.set_enabled(False) + + fr = Widgets.Frame("Plot Maker") + fr.set_widget(w) + row = Widgets.HBox() + row.set_spacing(0) + row.set_border_width(0) + row.add_widget(fr, stretch=0) + row.add_widget(Widgets.Label(''), stretch=1) + vbox.add_widget(row, stretch=0) + + container.add_widget(sw, stretch=1) + self.gui_up = True -# END + def start(self): + self.redo() + + def stop(self): + self.tab = None + self.gui_up = False + + def close(self): + # NOTE: this shouldn't be called under normal usage + self.fv.stop_local_plugin(self.chname, str(self)) + + def _set_combobox(self, combobox, vals, default=0): + """Populate combobox with given list.""" + combobox.clear() + for val in vals: + combobox.append_text(val) + if default > len(vals): + default = 0 + val = vals[default] + combobox.show_text(val) + return val + + def setup_table(self, dataobj): + # Generate column indices + self.w.x_col.set_enabled(True) + self.w.y_col.set_enabled(True) + self.w.make_plot.set_enabled(True) + # Generate column indices + self.tab = dataobj.get_data() + self._idx = np.arange(len(self.tab)) + + # Populate combobox with table column names + cols = [self._idxname] + self.tab.colnames + if cols != self.cols: + self.cols = cols + self.x_col = self._set_combobox(self.w.x_col, self.cols, default=1) + self.y_col = self._set_combobox(self.w.y_col, self.cols, default=2) + + def clear(self): + self.tab = None + self.cols = [] + self._idx = [] + self._idxname = '_idx' + self.x_col = '' + self.y_col = '' + + def redo(self): + """This is called when a new image arrives or the data in the + existing image changes. + """ + if not self.gui_up: + return + + dataobj = self.table_viewer.get_dataobj() + if isinstance(dataobj, AstroTable): + self.setup_table(dataobj) + + else: + self.logger.info("not able to process this object") + + name = self.trunc(dataobj.get('name', 'Noname')) + self.w.name.set_text(name) + + # LOGIC + + def _get_label(self, axis): + """Return plot label for column for the given axis.""" + + if axis == 'x': + colname = self.x_col + else: # y + colname = self.y_col + + if colname == self._idxname: + label = 'Index' + else: + col = self.tab[colname] + if col.unit: + label = '{0} ({1})'.format(col.name, col.unit) + else: + label = col.name + + return label + + def x_select_cb(self, w, index): + """Callback to set X-axis column.""" + try: + self.x_col = self.cols[index] + except IndexError as e: + self.logger.error(str(e)) + + def y_select_cb(self, w, index): + """Callback to set Y-axis column.""" + try: + self.y_col = self.cols[index] + except IndexError as e: + self.logger.error(str(e)) + + def make_plot_cb(self, w): + if self.x_col == self._idxname: + x_data = self._idx + else: + x_data = self.tab[self.x_col].data + + if self.y_col == self._idxname: + y_data = self._idx + else: + y_data = self.tab[self.y_col].data + + x_label = self._get_label('x') + y_label = self._get_label('y') + dataobj = self.table_viewer.get_dataobj() + name = "plot_{}".format(time.time()) + #title = self.trunc(dataobj.get('name', 'NoName')) + title = name + + if self.tab.masked: + if self.x_col == self._idxname: + x_mask = np.ones_like(self._idx, dtype=bool) + else: + x_mask = ~self.tab[self.x_col].mask + + if self.y_col == self._idxname: + y_mask = np.ones_like(self._idx, dtype=bool) + else: + y_mask = ~self.tab[self.y_col].mask + + mask = x_mask & y_mask + x_data = x_data[mask] + y_data = y_data[mask] + + if len(x_data) > 1: + i = np.argsort(x_data) # Sort X-axis to avoid messy line plot + x_data = x_data[i] + y_data = y_data[i] + + plot = Plotable(logger=self.logger) + plot.set_titles(x_axis=x_label, y_axis=y_label, title=title) + plot.set_grid(True) + + plot.set(name=name, path=None, nothumb=False) + + canvas = plot.get_canvas() + dc = get_canvas_types() + points = np.array((x_data, y_data)).T + canvas.add(dc.Path(points, color='black', linewidth=1, + alpha=1.0), + redraw=False) + + self.channel.add_image(plot) + + def __str__(self): + return 'info_ginga_table' diff --git a/ginga/rv/plugins/Operations.py b/ginga/rv/plugins/Operations.py index cf7e84fe0..06041c212 100644 --- a/ginga/rv/plugins/Operations.py +++ b/ginga/rv/plugins/Operations.py @@ -172,7 +172,7 @@ def start_operation_cb(self, name, ptype, spec): idx = self.w.channel.get_index() chname = str(self.w.channel.get_alpha(idx)) - self.fv.error_wrap(self.fv.start_local_plugin, chname, name, None) + self.fv.error_wrap(self.fv.start_local_plugin, chname, name) def set_operation_cb(self, menuname, name, ptype, spec): self._start_op_args = (name, ptype, spec) diff --git a/ginga/rv/plugins/Pan.py b/ginga/rv/plugins/Pan.py index 3ef02761b..798c6c1b6 100644 --- a/ginga/rv/plugins/Pan.py +++ b/ginga/rv/plugins/Pan.py @@ -280,7 +280,6 @@ def redraw_cb(self, fitsimage, whence, channel): whence=whence) paninfo.panimage.zoom_fit() self.panset(channel.fitsimage, channel, paninfo) - pass return True # LOGIC diff --git a/ginga/rv/plugins/Thumbs.py b/ginga/rv/plugins/Thumbs.py index e275a33ae..d225ad045 100644 --- a/ginga/rv/plugins/Thumbs.py +++ b/ginga/rv/plugins/Thumbs.py @@ -301,18 +301,18 @@ def get_thumb_key(self, chname, imname, path): thumbkey = (chname, imname, path) return thumbkey - def add_image_cb(self, shell, chname, image, info): + def add_image_cb(self, fv, chname, image, info): """This callback happens when an image is loaded into one of the channels in the Ginga shell. """ channel = self.fv.get_channel(chname) genthumb = channel.settings.get('genthumb', True) - if not genthumb: + if not genthumb or image.get('nothumb', False): return False - self.fv.gui_do(self._add_image, shell, chname, image, info) + self.fv.gui_do(self._add_image, fv, chname, image, info) - def _add_image(self, shell, chname, image, info): + def _add_image(self, fv, chname, image, info): # invoked via add_image_cb() self.fv.assert_gui_thread() @@ -343,10 +343,10 @@ def _add_image(self, shell, chname, image, info): image, extras, channel.fitsimage) extras.rgbimg = thumb_image - self._add_image_info(shell, channel, info) + self._add_image_info(fv, channel, info) return True - def add_image_info_cb(self, shell, channel, info): + def add_image_info_cb(self, fv, channel, info): """This callback happens when an image is loaded into one of the channels in the Ginga shell OR information about an image (without) the actual image data is loaded into the channel (a lazy load). @@ -355,12 +355,12 @@ def add_image_info_cb(self, shell, channel, info): `add_image_cb` and `add_image_info_cb` will be called. """ genthumb = channel.settings.get('genthumb', True) - if not genthumb: + if not genthumb or info.get('nothumb', False): return False - self.fv.gui_do(self._add_image_info, shell, channel, info) + self.fv.gui_do(self._add_image_info, fv, channel, info) - def _add_image_info(self, shell, channel, info): + def _add_image_info(self, fv, channel, info): # invoked via add_image_info_cb() self.fv.assert_gui_thread() @@ -391,7 +391,7 @@ def _add_image_info(self, shell, channel, info): return True - def remove_image_info_cb(self, shell, channel, image_info): + def remove_image_info_cb(self, fv, channel, image_info): """This callback is called when an image is removed from a channel in the Ginga shell. """ @@ -464,7 +464,7 @@ def clear(self): self.timer_update.set(self.update_interval) - def add_channel_cb(self, shell, channel): + def add_channel_cb(self, fv, channel): """Called when a channel is added from the main interface. Parameter is channel (a bunch). """ @@ -481,7 +481,7 @@ def add_channel_cb(self, shell, channel): # add old highlight set to channel external data channel.extdata.setdefault('thumbs_old_highlight', set([])) - def focus_cb(self, shell, channel): + def focus_cb(self, fv, channel): """This callback is called when a channel viewer is focused. We use this to highlight the proper thumbs in the Thumbs pane. """ @@ -812,7 +812,7 @@ def redo_thumbnail_image(self, channel, image, bnch, save_thumb=None): self.fv.update_pending() - def delete_channel_cb(self, shell, channel): + def delete_channel_cb(self, fv, channel): """Called when a channel is deleted from the main interface. Parameter is channel (a bunch). """ diff --git a/ginga/rv/plugins/Toolbar.py b/ginga/rv/plugins/Toolbar.py index 02b268b85..6b0c5e235 100644 --- a/ginga/rv/plugins/Toolbar.py +++ b/ginga/rv/plugins/Toolbar.py @@ -19,37 +19,252 @@ from ginga.misc import Bunch from ginga.events import KeyEvent from ginga import GingaPlugin +from ginga.util import viewer as gviewer -__all__ = ['Toolbar'] +__all__ = ['Toolbar', 'Toolbar_Common', 'Toolbar_Ginga_Image', + 'Toolbar_Ginga_Plot', 'Toolbar_Ginga_Table'] class Toolbar(GingaPlugin.GlobalPlugin): def __init__(self, fv): # superclass defines some variables for us, like logger - super(Toolbar, self).__init__(fv) + super().__init__(fv) # active view - self.active = None + self.channel = None + self.opname_prefix = 'Toolbar_' + + # get local plugin preferences + prefs = self.fv.get_preferences() + self.settings = prefs.create_category('plugin_Toolbar') + self.settings.add_defaults(close_unfocused_toolbars=True) + self.settings.load(onError='silent') + + fv.add_callback('add-channel', self.add_channel_cb) + fv.add_callback('delete-channel', self.delete_channel_cb) + fv.add_callback('channel-change', self.focus_cb) + fv.add_callback('viewer-select', self.viewer_select_cb) + + def build_gui(self, container): + self.w.nb = Widgets.StackWidget() + + container.add_widget(self.w.nb, stretch=1) + + # CALLBACKS + + def add_channel_cb(self, fv, channel): + if self.channel is None: + self.focus_cb(fv, channel) + + def delete_channel_cb(self, fv, channel): + self.logger.debug("delete channel %s" % (channel.name)) + if channel is self.channel: + self.channel = None + + def focus_cb(self, fv, channel): + self.logger.debug("{} focused".format(channel.name)) + if channel is not self.channel: + old_channel, self.channel = self.channel, channel + + if self.channel is not None: + opname = self.get_opname(self.channel) + self.logger.debug(f"starting {opname} in {channel}") + self.start_local_plugin(self.channel, opname) + + # NOTE: can stop toolbar plugins that aren't focused + # but it is more efficient to keep them open + if self.settings.get('close_unfocused_toolbars', False): + if old_channel is not None: + opname = self.get_opname(old_channel) + self.logger.debug(f"stopping {opname} in {old_channel}") + self.stop_local_plugin(old_channel, opname) + + return True + + def viewer_select_cb(self, fv, channel, old_viewer, new_viewer): + self.logger.debug("viewer changed: {}".format(new_viewer.name)) + if channel is self.channel and channel is not None: + #opname = self.get_opname(self.channel) + opname = self.opname_prefix + new_viewer.vname.replace(' ', '_') + self.start_local_plugin(self.channel, opname) + + if old_viewer is not None and old_viewer is not new_viewer: + opname = self.opname_prefix + old_viewer.vname.replace(' ', '_') + self.stop_local_plugin(self.channel, opname) + + def redo(self, channel, dataobj): + # NOTE: we need to call redo() specifically for our toolbars + # because they are not started nor managed like regular local + # plugins by the core, they are managed by us + opname = self.get_opname(channel) + opmon = channel.opmon + p_obj = opmon.get_plugin(opname) + try: + p_obj.redo() + except Exception as e: + self.logger.error(f"error updating toolbar {opmon}: {e}") + + # LOGIC + + def get_opname(self, channel): + opname = self.opname_prefix + channel.viewer.vname.replace(' ', '_') + return opname + + def start_local_plugin(self, channel, opname, future=None): + wname = "{}_{}".format(channel.name, opname) + if wname in self.w and self.w[wname] is not None: + vbox = self.w[wname] + idx = self.w.nb.index_of(vbox) + self.logger.debug(f"raising {wname}") + self.w.nb.set_index(idx) + return + opmon = channel.opmon + p_obj = opmon.get_plugin(opname) + vbox = Widgets.VBox() + p_obj.build_gui(vbox) + self.w.nb.add_widget(vbox) + self.w[wname] = vbox + p_obj.start() + + def stop_local_plugin(self, channel, opname): + opmon = channel.opmon + p_obj = opmon.get_plugin(opname) + try: + p_obj.stop() + except Exception as e: + pass + wname = "{}_{}".format(channel.name, opname) + try: + vbox = self.w[wname] + self.w.nb.remove(vbox) + self.w[wname] = None + vbox.delete() + except Exception as e: + self.logger.error(f"error stopping plugin '{wname}'") + + def __str__(self): + return 'toolbar' + + +class Toolbar_Common(GingaPlugin.LocalPlugin): + + def __init__(self, fv, fitsimage): + # superclass defines some variables for us, like logger + super().__init__(fv, fitsimage) + # holds our gui widgets self.w = Bunch.Bunch() + self.viewers = [] + self.layout_common = [ + # (Name, type, icon, tooltip) + ("viewer", 'combobox', None, "Select compatible viewer", + self.viewer_cb), + ("Up", 'button', 'up', "Go to previous image in channel", + lambda w: self.fv.prev_img()), + ("Down", 'button', 'down', "Go to next image in channel", + lambda w: self.fv.next_img()), + ("---",)] + + self.gui_up = False + + def close(self): + self.fv.stop_local_plugin(self.chname, str(self)) + + def stop(self): + self.viewers = [] self.gui_up = False + def start_local_plugin(self, name): + self.fv.start_operation(name) + + def start_global_plugin(self, name): + self.fv.start_global_plugin(name, raise_tab=True) + + def build_toolbar(self, tb_w, layout): + + for tup in layout: + name = tup[0] + if name == '---': + tb_w.add_separator() + continue + if tup[1] not in ['button', 'toggle']: + btn = Widgets.make_widget(name, tup[1]) + tb_w.add_widget(btn) + else: + iconpath = tup[2] + if not iconpath.startswith(os.path.sep): + iconpath = os.path.join(self.fv.iconpath, "%s.svg" % (iconpath)) + btn = tb_w.add_action(None, toggle=(tup[1] == 'toggle'), + iconpath=iconpath, iconsize=(24, 24)) + if tup[3]: + btn.set_tooltip(tup[3]) + if tup[4]: + btn.add_callback('activated', tup[4]) + + # add to our widget dict + self.w[Widgets.name_mangle(name, pfx='btn_')] = btn + + return tb_w + + def viewer_cb(self, w, idx): + vinfo = self.viewers[idx] + self.logger.debug(f"viewer {vinfo.name} selected") + dataobj = self.channel.viewer.get_dataobj() + self.channel.open_with_viewer(vinfo, dataobj) + + def _update_viewer_selection(self): + if not self.gui_up: + return + + dataobj = self.channel.get_current_image() + + # find available viewers that can view this kind of object + viewers = gviewer.get_viewers(dataobj) + if viewers != self.viewers: + # repopulate viewer selector + self.viewers = viewers + new_names = [viewer.name for viewer in viewers] + self.w.btn_viewer.clear() + self.logger.debug("available viewers for {} are {}".format(type(dataobj), new_names)) + for name in new_names: + self.w.btn_viewer.append_text(name) + # set the box to the viewer we have selected + cur_name = self.channel.viewer.vname + if cur_name in new_names: + self.w.btn_viewer.set_text(cur_name) + + def __str__(self): + return 'toolbarbase' + + +class Toolbar_Ginga_Image(Toolbar_Common): + """A toolbar for the Ginga Image viewer. + """ + + def __init__(self, fv, chviewer): + # superclass defines some variables for us, like logger + super().__init__(fv, chviewer) + # get local plugin preferences prefs = self.fv.get_preferences() - self.settings = prefs.create_category('plugin_Toolbar') + self.settings = prefs.create_category('toolbar_Ginga_Image') self.settings.load(onError='silent') self.modetype = self.settings.get('mode_type', 'oneshot') - fv.set_callback('add-channel', self.add_channel_cb) - fv.set_callback('delete-channel', self.delete_channel_cb) - fv.set_callback('channel-change', self.focus_cb) - fv.add_callback('add-image-info', self._ch_image_added_cb) - fv.add_callback('remove-image-info', self._ch_image_removed_cb) + chviewer.add_callback('transform', self.viewer_transform_cb) + + bm = chviewer.get_bindmap() + bm.add_callback('mode-set', self.mode_set_cb) self.layout = [ # (Name, type, icon, tooltip) + # ("Up", 'button', 'up', "Go to previous image in channel", + # lambda w: self.fv.prev_img()), + # ("Down", 'button', 'down', "Go to next image in channel", + # lambda w: self.fv.next_img()), + # ("---",), ("FlipX", 'toggle', 'flip_x', "Flip image in X axis", self.flipx_cb), ("FlipY", 'toggle', 'flip_y', "Flip image in Y axis", @@ -66,11 +281,6 @@ def __init__(self, fv): ("OrientLH", 'button', 'orient_ne', "Orient image N=Up E=Left", self.orient_lh_cb), ("---",), - ("Up", 'button', 'up', "Go to previous image in channel", - lambda w: self.fv.prev_img()), - ("Down", 'button', 'down', "Go to next image in channel", - lambda w: self.fv.next_img()), - ("---",), ("Zoom In", 'button', 'zoom_in', "Zoom in", lambda w: self.fv.zoom_in()), ("Zoom Out", 'button', 'zoom_out', "Zoom out", @@ -115,73 +325,34 @@ def __init__(self, fv): self.reset_contrast_cb), ("---",), ("Preferences", 'button', 'settings', "Set channel preferences (in focused channel)", - lambda w: self.start_plugin_cb('Preferences')), + lambda w: self.start_local_plugin('Preferences')), ("FBrowser", 'button', 'folder_open', "Open file (in focused channel)", - lambda w: self.start_plugin_cb('FBrowser')), + lambda w: self.start_local_plugin('FBrowser')), ("MultiDim", 'button', 'layers', "Select HDUs or cube slices (in focused channel)", - lambda w: self.start_plugin_cb('MultiDim')), + lambda w: self.start_local_plugin('MultiDim')), ("Header", 'button', 'tags', "View image metadata (Header plugin)", - lambda w: self.start_global_plugin_cb('Header')), + lambda w: self.start_global_plugin('Header')), ("ZoomPlugin", 'button', 'microscope', "Magnify detail (Zoom plugin)", - lambda w: self.start_global_plugin_cb('Zoom'))] + lambda w: self.start_global_plugin('Zoom'))] def build_gui(self, container): self.orientation = Widgets.get_orientation(container) - tb = Widgets.Toolbar(orientation=self.orientation) - self.w.toolbar = tb - - for tup in self.layout: - - name = tup[0] - if name == '---': - tb.add_separator() - continue - if tup[1] not in ['button', 'toggle']: - btn = Widgets.make_widget(name, tup[1]) - tb.add_widget(btn) - else: - iconpath = tup[2] - if not iconpath.startswith(os.path.sep): - iconpath = os.path.join(self.fv.iconpath, "%s.svg" % (iconpath)) - btn = tb.add_action(None, toggle=(tup[1] == 'toggle'), - iconpath=iconpath, iconsize=(24, 24)) - if tup[3]: - btn.set_tooltip(tup[3]) - if tup[4]: - btn.add_callback('activated', tup[4]) - - # add to our widget dict - self.w[Widgets.name_mangle(name, pfx='btn_')] = btn + tb_w = Widgets.Toolbar(orientation=self.orientation) - container.add_widget(tb, stretch=0) + self.build_toolbar(tb_w, self.layout_common) + self.build_toolbar(tb_w, self.layout) + self.w.toolbar = tb_w + container.add_widget(tb_w, stretch=1) self.gui_up = True - # CALLBACKS - - def add_channel_cb(self, viewer, channel): - chviewer = channel.fitsimage - chviewer.add_callback('transform', self.viewer_transform_cb) - - bm = chviewer.get_bindmap() - bm.add_callback('mode-set', self.mode_set_cb, chviewer) - - def delete_channel_cb(self, viewer, channel): - self.logger.debug("delete channel %s" % (channel.name)) - # we don't keep around any baggage on channels so nothing - # to delete - - def focus_cb(self, viewer, channel): - self.update_channel_buttons(channel) + def start(self): + self._update_toolbar_state() - chviewer = channel.fitsimage - self.active = chviewer - self._update_toolbar_state(chviewer) - return True + # CALLBACKS def center_image_cb(self, w): - view = self._get_viewer() - view.center_image() + self.fitsimage.center_image() return True def reset_contrast_cb(self, w): @@ -191,18 +362,15 @@ def reset_contrast_cb(self, w): return True def auto_levels_cb(self, w): - view = self._get_viewer() - view.auto_levels() + self.fitsimage.auto_levels() return True def rot90_cb(self, w): - view = self._get_viewer() - view.rotate_delta(90.0) + self.fitsimage.rotate_delta(90.0) return True def rotn90_cb(self, w): - view = self._get_viewer() - view.rotate_delta(-90.0) + self.fitsimage.rotate_delta(-90.0) return True def orient_lh_cb(self, w): @@ -218,82 +386,56 @@ def orient_rh_cb(self, w): return True def reset_all_transforms_cb(self, w): - view = self._get_viewer() + view = self.fitsimage with view.suppress_redraw: view.rotate(0.0) view.transform(False, False, False) - - return True - - def start_plugin_cb(self, name): - self.fv.start_operation(name) - return True - - def start_global_plugin_cb(self, name): - self.fv.start_global_plugin(name, raise_tab=True) return True def flipx_cb(self, w, tf): - view = self._get_viewer() + view = self.fitsimage flip_x, flip_y, swap_xy = view.get_transforms() flip_x = tf view.transform(flip_x, flip_y, swap_xy) return True def flipy_cb(self, w, tf): - view = self._get_viewer() + view = self.fitsimage flip_x, flip_y, swap_xy = view.get_transforms() flip_y = tf view.transform(flip_x, flip_y, swap_xy) return True def swapxy_cb(self, w, tf): - view = self._get_viewer() + view = self.fitsimage flip_x, flip_y, swap_xy = view.get_transforms() swap_xy = tf view.transform(flip_x, flip_y, swap_xy) return True def mode_cb(self, tf, modename): - if self.active is None: - self.active = self._get_viewer() - chviewer = self.active - if chviewer is None: - return - bm = chviewer.get_bindmap() + bm = self.fitsimage.get_bindmap() if not tf: - bm.reset_mode(chviewer) + bm.reset_mode(self.fitsimage) self.fv.show_status("") return True bm.set_mode(modename) # just in case mode change failed - self._update_toolbar_state(chviewer) + self._update_toolbar_state() self.fv.show_status(f"Type 'h' in the viewer to show help for mode {modename}") return True - def mode_set_cb(self, bm, mode, mtype, chviewer): + def mode_set_cb(self, bm, mode, mtype): # called whenever the user interaction mode is changed # in the viewer - if self.active is None: - self.active = self._get_viewer() - if chviewer != self.active: - return True - self._update_toolbar_state(chviewer) + self._update_toolbar_state() return True def viewer_transform_cb(self, chviewer): # called whenever the transform (flip x/y, swap axes) is done # in the viewer - if self.active is None: - self.active = self._get_viewer() - if chviewer != self.active: - return True - self._update_toolbar_state(chviewer) - return True - - def new_image_cb(self, chviewer, image): - self._update_toolbar_state(chviewer) + self._update_toolbar_state() return True def set_locked_cb(self, w, tf): @@ -301,11 +443,7 @@ def set_locked_cb(self, w, tf): modetype = 'locked' else: modetype = 'oneshot' - if self.active is None: - self.active = self._get_viewer() - chviewer = self.active - if chviewer is None: - return + chviewer = self.fitsimage # get current bindmap, make sure that the mode is consistent # with current lock button @@ -318,45 +456,36 @@ def set_locked_cb(self, w, tf): # turning off lock also resets the mode bm.reset_mode(chviewer) - self._update_toolbar_state(chviewer) + self._update_toolbar_state() return True # LOGIC - def _get_viewer(self): - channel = self.fv.get_channel_info() - return channel.fitsimage - def _get_mode(self, mode_name): - channel = self.fv.get_channel_info() - view = channel.fitsimage + view = self.fitsimage bd = view.get_bindings() mode = bd.get_mode_obj(mode_name) return view, mode - def _ch_image_added_cb(self, shell, channel, info): - if channel != shell.get_current_channel(): - return - self.update_channel_buttons(channel) + def redo(self): + self._update_toolbar_state() - def _ch_image_removed_cb(self, shell, channel, info): - if channel != shell.get_current_channel(): - return - self.update_channel_buttons(channel) + def clear(self): + self._update_toolbar_state() - def update_channel_buttons(self, channel): + def _update_toolbar_state(self): if not self.gui_up: return - # Update toolbar channel buttons - enabled = len(channel) > 1 - self.w.btn_up.set_enabled(enabled) - self.w.btn_down.set_enabled(enabled) - - def _update_toolbar_state(self, chviewer): - if (chviewer is None) or (not self.gui_up): - return self.logger.debug("updating toolbar state") + chviewer = self.fitsimage try: + self._update_viewer_selection() + + # Update toolbar channel buttons + enabled = len(self.channel) > 1 + self.w.btn_up.set_enabled(enabled) + self.w.btn_down.set_enabled(enabled) + # update transform toggles flipx, flipy, swapxy = chviewer.get_transforms() # toolbar follows view @@ -387,7 +516,175 @@ def _update_toolbar_state(self, chviewer): raise e def __str__(self): - return 'toolbar' + return 'toolbar_ginga_image' + + +class Toolbar_Ginga_Plot(Toolbar_Common): + """A toolbar for the Ginga Plot viewer. + """ + + def __init__(self, fv, chviewer): + # superclass defines some variables for us, like logger + super().__init__(fv, chviewer) + + # get local plugin preferences + prefs = self.fv.get_preferences() + self.settings = prefs.create_category('toolbar_Ginga_Plot') + self.settings.add_defaults(zoom_bump_pct=1.1) + self.settings.load(onError='silent') + + self.layout = [ + # (Name, type, icon, tooltip) + ("Zoom In", 'button', 'zoom_in', "Zoom in", + lambda w: self.zoom_in()), + ("Zoom Out", 'button', 'zoom_out', "Zoom out", + lambda w: self.zoom_out()), + ("Zoom Fit", 'button', 'zoom_fit', "Fit plot to window size", + lambda w: self.zoom_fit()), + ("Zoom Fit X", 'button', 'zoom_fit_x', "Fit X axis to window size", + lambda w: self.zoom_fit_x()), + ("Zoom Fit Y", 'button', 'zoom_fit_y', "Fit Y axis to window size", + lambda w: self.zoom_fit_y()), + ("---",), + ("Preferences", 'button', 'settings', "Set channel preferences (in focused channel)", + lambda w: self.start_local_plugin('Preferences')), + ("FBrowser", 'button', 'folder_open', "Open file (in focused channel)", + lambda w: self.start_local_plugin('FBrowser')), + ("MultiDim", 'button', 'layers', "Select HDUs or cube slices (in focused channel)", + lambda w: self.start_local_plugin('MultiDim')), + ("Header", 'button', 'tags', "View image metadata (Header plugin)", + lambda w: self.start_global_plugin('Header'))] + + def build_gui(self, container): + self.orientation = Widgets.get_orientation(container) + tb_w = Widgets.Toolbar(orientation=self.orientation) + + self.build_toolbar(tb_w, self.layout_common) + self.build_toolbar(tb_w, self.layout) + self.w.toolbar = tb_w + + container.add_widget(tb_w, stretch=1) + self.gui_up = True + + def start(self): + self._update_toolbar_state() + + # CALLBACKS + + def zoom_in(self): + viewer = self.channel.get_viewer('Ginga Plot') + pct = self.settings['zoom_bump_pct'] + viewer.zoom_plot(1 / pct, 1 / pct) + + def zoom_out(self): + viewer = self.channel.get_viewer('Ginga Plot') + pct = self.settings['zoom_bump_pct'] + viewer.zoom_plot(pct, pct) + + def zoom_fit_x(self): + viewer = self.channel.get_viewer('Ginga Plot') + viewer.zoom_fit(axis='x') + + def zoom_fit_y(self): + viewer = self.channel.get_viewer('Ginga Plot') + viewer.zoom_fit(axis='y') + + def zoom_fit(self): + viewer = self.channel.get_viewer('Ginga Plot') + viewer.zoom_fit(axis='xy') + + # LOGIC + + def redo(self): + self._update_toolbar_state() + + def clear(self): + self._update_toolbar_state() + + def _update_toolbar_state(self): + if not self.gui_up: + return + self.logger.debug("updating toolbar state") + try: + self._update_viewer_selection() + + # Update toolbar channel buttons + enabled = len(self.channel) > 1 + self.w.btn_up.set_enabled(enabled) + self.w.btn_down.set_enabled(enabled) + + except Exception as e: + self.logger.error("error updating toolbar: %s" % str(e)) + raise e + + def __str__(self): + return 'toolbar_ginga_plot' + + +class Toolbar_Ginga_Table(Toolbar_Common): + """A toolbar for the Ginga Table viewer. + """ + + def __init__(self, fv, chviewer): + # superclass defines some variables for us, like logger + super().__init__(fv, chviewer) + + # get local plugin preferences + prefs = self.fv.get_preferences() + self.settings = prefs.create_category('toolbar_Ginga_Table') + self.settings.load(onError='silent') + + self.layout = [ + # (Name, type, icon, tooltip) + ("Preferences", 'button', 'settings', "Set channel preferences (in focused channel)", + lambda w: self.start_local_plugin('Preferences')), + ("FBrowser", 'button', 'folder_open', "Open file (in focused channel)", + lambda w: self.start_local_plugin('FBrowser')), + ("MultiDim", 'button', 'layers', "Select HDUs or cube slices (in focused channel)", + lambda w: self.start_local_plugin('MultiDim')), + ("Header", 'button', 'tags', "View image metadata (Header plugin)", + lambda w: self.start_global_plugin('Header'))] + + def build_gui(self, container): + self.orientation = Widgets.get_orientation(container) + tb_w = Widgets.Toolbar(orientation=self.orientation) + + self.build_toolbar(tb_w, self.layout_common) + self.build_toolbar(tb_w, self.layout) + self.w.toolbar = tb_w + + container.add_widget(tb_w, stretch=1) + self.gui_up = True + + def start(self): + self._update_toolbar_state() + + # LOGIC + + def redo(self): + self._update_toolbar_state() + + def clear(self): + self._update_toolbar_state() + + def _update_toolbar_state(self): + if not self.gui_up: + return + self.logger.debug("updating toolbar state") + try: + self._update_viewer_selection() + + # Update toolbar channel buttons + enabled = len(self.channel) > 1 + self.w.btn_up.set_enabled(enabled) + self.w.btn_down.set_enabled(enabled) + + except Exception as e: + self.logger.error("error updating toolbar: %s" % str(e)) + raise e + + def __str__(self): + return 'toolbar_ginga_table' # Append module docstring with config doc for auto insert by Sphinx. diff --git a/ginga/util/plots.py b/ginga/util/plots.py index 0b2eb47b8..dded0c0bb 100644 --- a/ginga/util/plots.py +++ b/ginga/util/plots.py @@ -37,6 +37,7 @@ def __init__(self, figure=None, logger=None, width=500, height=500): self.logx = False self.logy = False + self.zoom_rate = 1.1 self.xdata = [] self.ydata = [] @@ -177,34 +178,54 @@ def _plot_motion_notify(self, event): def _plot_key_press(self, event): self.make_callback('key-press', event) + def zoom_plot(self, pct_x, pct_y, redraw=True): + + x1, x2 = self.ax.get_xlim() + y1, y2 = self.ax.get_ylim() + set_lim = False + + if pct_x is not None: + xrng = x2 - x1 + xinc = (pct_x * xrng - xrng) * 0.5 + x1, x2 = x1 - xinc, x2 + xinc + self.ax.set_xlim(x1, x2) + set_lim = True + + if pct_y is not None: + yrng = y2 - y1 + yinc = (pct_y * yrng - yrng) * 0.5 + y1, y2 = y1 - yinc, y2 + yinc + self.ax.set_ylim(y1, y2) + set_lim = True + + if set_lim: + self.make_callback('limits-set', + dict(x_lim=(x1, x2), y_lim=(y1, y2))) + if redraw: + self.draw() + def plot_do_zoom(self, cb_obj, event): """Can be set as the callback function for the 'scroll' event to zoom the plot. """ if not self.can.zoom: return + # Matplotlib only gives us the number of steps of the scroll, # positive for up and negative for down. if event.step > 0: - delta = 0.9 + delta = self.zoom_rate ** -2 elif event.step < 0: - delta = 1.1 - - x1, x2 = self.ax.get_xlim() - xrng = x2 - x1 - xinc = (delta * xrng - xrng) * 0.5 - x1, x2 = x1 - xinc, x2 + xinc - - y1, y2 = self.ax.get_ylim() - yrng = y2 - y1 - yinc = (delta * yrng - yrng) * 0.5 - y1, y2 = y1 - yinc, y2 + yinc - - self.ax.set_xlim(x1, x2) - self.ax.set_ylim(y1, y2) - self.make_callback('limits-set', dict(x_lim=(x1, x2), y_lim=(y1, y2))) - - self.draw() + delta = self.zoom_rate ** 2 + + delta_x = delta_y = delta + if 'ctrl' in event.modifiers: + # only horizontal + delta_y = 1.0 + elif 'shift' in event.modifiers: + # only horizontal + delta_x = 1.0 + self.zoom_plot(delta_x, delta_y) return True def get_axes_size_in_px(self): @@ -225,23 +246,31 @@ def plot_do_pan(self, cb_obj, event): return True - def pan_plot(self, xnew, ynew): + def pan_plot(self, xnew, ynew, redraw=True): x1, x2 = self.ax.get_xlim() - xrng = x2 - x1 - xinc = xrng * 0.5 - x1, x2 = xnew - xinc, xnew + xinc - y1, y2 = self.ax.get_ylim() - yrng = y2 - y1 - yinc = yrng * 0.5 - y1, y2 = ynew - yinc, ynew + yinc - - self.ax.set_xlim(x1, x2) - self.ax.set_ylim(y1, y2) - self.make_callback('limits-set', dict(x_lim=(x1, x2), y_lim=(y1, y2))) - - self.draw() + set_lim = False + + if xnew is not None: + xrng = x2 - x1 + xinc = xrng * 0.5 + x1, x2 = xnew - xinc, xnew + xinc + self.ax.set_xlim(x1, x2) + set_lim = True + + if ynew is not None: + yrng = y2 - y1 + yinc = yrng * 0.5 + y1, y2 = ynew - yinc, ynew + yinc + self.ax.set_ylim(y1, y2) + set_lim = True + + if set_lim: + self.make_callback('limits-set', + dict(x_lim=(x1, x2), y_lim=(y1, y2))) + if redraw: + self.draw() class HistogramPlot(Plot): diff --git a/ginga/util/viewer.py b/ginga/util/viewer.py index 0539f6363..5579f49c5 100644 --- a/ginga/util/viewer.py +++ b/ginga/util/viewer.py @@ -63,3 +63,7 @@ def get_viewer_names(dataobj): can view `dataobj`. """ return [vinfo.name for vinfo in get_viewers(dataobj)] + + +def get_vinfo(vname): + return viewer_db[vname]