diff --git a/jdaviz/app.py b/jdaviz/app.py index d2ba665d2e..9397646084 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -77,6 +77,10 @@ os.path.join(os.path.dirname(__file__), 'components/plugin_subset_select.vue')) +ipyvue.register_component_from_file(None, 'plugin-viewer-select', + os.path.join(os.path.dirname(__file__), + 'components/plugin_viewer_select.vue')) + # Register pure vue component. This allows us to do recursive component instantiation only in the # vue component file ipyvue.register_component_from_file('g-viewer-tab', "container.vue", __file__) diff --git a/jdaviz/components/plugin_viewer_select.vue b/jdaviz/components/plugin_viewer_select.vue new file mode 100644 index 0000000000..1df377e8c9 --- /dev/null +++ b/jdaviz/components/plugin_viewer_select.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/jdaviz/configs/default/plugins/export_plot/export_plot.py b/jdaviz/configs/default/plugins/export_plot/export_plot.py index 6cb57f8adb..6d71834183 100644 --- a/jdaviz/configs/default/plugins/export_plot/export_plot.py +++ b/jdaviz/configs/default/plugins/export_plot/export_plot.py @@ -1,41 +1,22 @@ -from traitlets import List, Unicode - -from jdaviz.core.events import (ViewerAddedMessage, ViewerRemovedMessage) from jdaviz.core.registries import tray_registry -from jdaviz.core.template_mixin import TemplateMixin +from jdaviz.core.template_mixin import TemplateMixin, ViewerSelectMixin __all__ = ['ExportViewer'] @tray_registry('g-export-plot', label="Export Plot") -class ExportViewer(TemplateMixin): +class ExportViewer(TemplateMixin, ViewerSelectMixin): template_file = __file__, "export_plot.vue" - viewer_items = List([]).tag(sync=True) - selected_viewer = Unicode("").tag(sync=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.hub.subscribe(self, ViewerAddedMessage, - handler=lambda _: self._on_viewers_changed()) - self.hub.subscribe(self, ViewerRemovedMessage, - handler=lambda _: self._on_viewers_changed()) - - # initialize viewer_items from original viewers - self._on_viewers_changed() - - def _on_viewers_changed(self): - self.viewer_items = self.app.get_viewer_ids() - if self.selected_viewer not in self.viewer_items: - # default to first entry, will trigger _on_viewer_select to set layer defaults - self.selected_viewer = self.viewer_items[0] if len(self.viewer_items) else "" - def vue_save_figure(self, filetype): """ Callback for save figure events in the front end viewer toolbars. Uses the bqplot.Figure save methods. """ - viewer = self.app.get_viewer_by_id(self.selected_viewer) + viewer = self.viewer.selected_obj if filetype == "png": viewer.figure.save_png() elif filetype == "svg": diff --git a/jdaviz/configs/default/plugins/export_plot/export_plot.vue b/jdaviz/configs/default/plugins/export_plot/export_plot.vue index 8f34b58d1f..cfb800ee5e 100644 --- a/jdaviz/configs/default/plugins/export_plot/export_plot.vue +++ b/jdaviz/configs/default/plugins/export_plot/export_plot.vue @@ -4,17 +4,14 @@ Export viewer plot as an image. - - - + -
+
Viewer and data/layer options. - - - + -
+
Viewer Options - + @@ -31,7 +28,7 @@ Layer Options - +
diff --git a/jdaviz/configs/imviz/plugins/compass/compass.py b/jdaviz/configs/imviz/plugins/compass/compass.py index 1e874a1872..00fe427fb9 100644 --- a/jdaviz/configs/imviz/plugins/compass/compass.py +++ b/jdaviz/configs/imviz/plugins/compass/compass.py @@ -1,48 +1,38 @@ -from traitlets import Unicode, List, observe +from traitlets import Unicode, observe -from jdaviz.core.events import (ViewerAddedMessage, ViewerRemovedMessage, - AddDataMessage, RemoveDataMessage) +from jdaviz.core.events import AddDataMessage, RemoveDataMessage from jdaviz.core.registries import tray_registry -from jdaviz.core.template_mixin import PluginTemplateMixin +from jdaviz.core.template_mixin import PluginTemplateMixin, ViewerSelectMixin __all__ = ['Compass'] @tray_registry('imviz-compass', label="Imviz Compass") -class Compass(PluginTemplateMixin): +class Compass(PluginTemplateMixin, ViewerSelectMixin): template_file = __file__, "compass.vue" - viewer_items = List([]).tag(sync=True) - selected_viewer = Unicode("").tag(sync=True) data_label = Unicode("").tag(sync=True) img_data = Unicode("").tag(sync=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.hub.subscribe(self, ViewerAddedMessage, handler=self._on_viewers_changed) - self.hub.subscribe(self, ViewerRemovedMessage, handler=self._on_viewers_changed) self.hub.subscribe(self, AddDataMessage, handler=self._on_viewer_data_changed) self.hub.subscribe(self, RemoveDataMessage, handler=self._on_viewer_data_changed) - self._on_viewers_changed() # Populate it on start-up - - def _on_viewers_changed(self, msg=None): - self.viewer_items = self.app.get_viewer_ids() - - # Selected viewer was removed but Imviz always has a default viewer to fall back on. - if self.selected_viewer not in self.viewer_items: - self.selected_viewer = f'{self.app.config}-0' - def _on_viewer_data_changed(self, msg=None): - if self.selected_viewer: - viewer = self.app.get_viewer_by_id(self.selected_viewer) + if self.viewer_selected: + viewer = self.viewer.selected_obj viewer.on_limits_change() # Force redraw - @observe("selected_viewer", "plugin_opened") + @observe("viewer_selected", "plugin_opened") def _compass_with_new_viewer(self, *args, **kwargs): + if not hasattr(self, 'viewer'): + # mixin object not yet initialized + return + # There can be only one! for vid, viewer in self.app._viewer_store.items(): - if vid == self.selected_viewer and self.plugin_opened: + if vid == self.viewer.selected_id and self.plugin_opened: viewer.compass = self viewer.on_limits_change() # Force redraw else: diff --git a/jdaviz/configs/imviz/plugins/compass/compass.vue b/jdaviz/configs/imviz/plugins/compass/compass.vue index 5f20538504..2d7b92167d 100644 --- a/jdaviz/configs/imviz/plugins/compass/compass.vue +++ b/jdaviz/configs/imviz/plugins/compass/compass.vue @@ -6,15 +6,12 @@ - - - + + + """ + def __init__(self, plugin, items, selected): + super().__init__(plugin, items=items, selected=selected) + + self.hub.subscribe(self, ViewerAddedMessage, handler=self._on_viewers_changed) + self.hub.subscribe(self, ViewerRemovedMessage, handler=self._on_viewers_changed) + self.add_observe(selected, self._selected_changed) + + # initialize viewer_items from original viewers + self._on_viewers_changed() + + @property + def ids(self): + return [item['id'] for item in self.items] + + @property + def references(self): + return [item['reference'] for item in self.items] + + @property + def ref_or_ids(self): + return [item['ref_or_id'] for item in self.items] + + @property + def selected_item(self): + for item in self.items: + if item['ref_or_id'] == self.selected: + return item + # try again but this time allow match to id alone. Note that _selected_changed + # will handle resetting the trait to the reference since it exists, but this + # will allow access to the underlying item/object for any observes in the meantime. + for item in self.items: + if item['id'] == self.selected: + return item + + @property + def selected_id(self): + return self.selected_item['id'] + + @property + def selected_obj(self): + return self.app.get_viewer_by_id(self.selected_id) + + def _selected_changed(self, event): + if event['new'] not in self.ref_or_ids: + if self.selected in self.ids: + # provided id in place of ref + self.selected = self.ref_or_ids[self.ids.index(self.selected)] + else: + self._handle_default() + raise ValueError(f"{event['new']} not one of {self.ref_or_ids}") + + def _handle_default(self): + if self.selected not in self.ref_or_ids: + # default to first entry, will trigger any observer on selected + self.selected = self.ref_or_ids[0] if len(self.items) else "" + + def _on_viewers_changed(self, msg=None): + # NOTE: _on_viewers_changed is passed without a msg object during init + # list of dictionaries with id, ref, ref_or_id + def _dict_from_viewer(viewer_item): + d = {'id': viewer_item['id']} + if viewer_item.get('reference') is not None: + d['reference'] = viewer_item['reference'] + d['ref_or_id'] = viewer_item['reference'] + else: + d['reference'] = None + d['ref_or_id'] = viewer_item['id'] + return d + + self.items = [_dict_from_viewer(self.app._viewer_item_by_id(vid)) + for vid, viewer in self.app._viewer_store.items() + if viewer.__class__.__name__ != 'MosvizTableViewer'] + self._handle_default() + + +class ViewerSelectMixin(VuetifyTemplate, HubListener): + """ + Applies the ViewerSelect component as a mixin in the base plugin. This + automatically adds traitlets as well as new properties to the plugin with minimal + extra code. For multiple instances or custom traitlet names/defaults, use the + SpectralSubsetSelect component instead. + + Traitlets (available from the plugin): + + * ``viewer_items`` + * ``viewer_selected`` + + Properties (available from the plugin): + + * ``viewer.ids`` + * ``viewer.references`` + * ``viewer.ref_or_ids`` + * ``viewer.selected_item`` + * ``viewer.selected_id`` + * ``viewer.selected_obj`` + + To use in a plugin: + + * add ``ViewerSelectMixin`` as a mixin to the class + * use the traitlets and properties above as needed (note the prefix for properties) + + Example template (label and hint are optional):: + + + + + """ + viewer_items = List().tag(sync=True) + viewer_selected = Unicode().tag(sync=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.viewer = ViewerSelect(self, 'viewer_items', 'viewer_selected') diff --git a/jdaviz/core/tests/test_template_mixin.py b/jdaviz/core/tests/test_template_mixin.py index e4a6672514..ad8d742c8c 100644 --- a/jdaviz/core/tests/test_template_mixin.py +++ b/jdaviz/core/tests/test_template_mixin.py @@ -1,3 +1,5 @@ +import pytest + from glue.core.roi import XRangeROI @@ -33,3 +35,28 @@ def test_spectralsubsetselect(specviz_helper, spectrum1d): assert p.spectral_subset.selected_obj is None p.spectral_subset_selected = 'Subset 1' assert p.spectral_subset.selected_obj is not None + + +@pytest.mark.filterwarnings('ignore:No observer defined on WCS') +def test_viewer_select(cubeviz_helper, spectrum1d_cube): + app = cubeviz_helper.app + app.add_data(spectrum1d_cube, 'test') + app.add_data_to_viewer("spectrum-viewer", "test") + app.add_data_to_viewer("flux-viewer", "test") + fv = app.get_viewer("flux-viewer") + sv = app.get_viewer("spectrum-viewer") + + # export plot uses the mixin + p = app.get_tray_item_from_name('g-export-plot') + assert len(p.viewer.ids) == 4 + assert len(p.viewer.references) == 4 + assert len(p.viewer.ref_or_ids) == 4 + assert p.viewer.selected_obj == fv + + # set by reference + p.viewer_selected = 'spectrum-viewer' + assert p.viewer.selected_obj == sv + + # try setting based on id instead of reference + p.viewer_selected = p.viewer.ids[0] + assert p.viewer_selected == p.viewer.ref_or_ids[0] diff --git a/jdaviz/core/tools.py b/jdaviz/core/tools.py index c5dd370562..a1d69e1dc7 100644 --- a/jdaviz/core/tools.py +++ b/jdaviz/core/tools.py @@ -146,7 +146,7 @@ def on_mouse_event(self, data): class _BaseSidebarShortcut(Tool): plugin_label = None # define in subclass - viewer_select_traitlet = 'selected_viewer' + viewer_select_traitlet = 'viewer_selected' def activate(self): jdaviz_state = self.viewer.jdaviz_app.state @@ -162,7 +162,7 @@ def activate(self): @viewer_tool class SidebarShortcutPlotOptions(_BaseSidebarShortcut): plugin_name = 'g-plot-options' - viewer_select_traitlet = 'selected_viewer' + viewer_select_traitlet = 'viewer_selected' icon = os.path.join(ICON_DIR, 'tune.svg') tool_id = 'jdaviz:sidebar_plot' @@ -173,7 +173,7 @@ class SidebarShortcutPlotOptions(_BaseSidebarShortcut): @viewer_tool class SidebarShortcutExportPlot(_BaseSidebarShortcut): plugin_name = 'g-export-plot' - viewer_select_traitlet = 'selected_viewer' + viewer_select_traitlet = 'viewer_selected' icon = os.path.join(ICON_DIR, 'image.svg') tool_id = 'jdaviz:sidebar_export' @@ -184,7 +184,7 @@ class SidebarShortcutExportPlot(_BaseSidebarShortcut): @viewer_tool class SidebarShortcutCompass(_BaseSidebarShortcut): plugin_name = 'imviz-compass' - viewer_select_traitlet = 'selected_viewer' + viewer_select_traitlet = 'viewer_selected' icon = os.path.join(ICON_DIR, 'compass.svg') tool_id = 'jdaviz:sidebar_compass'