diff --git a/glue_ar/common/scatter_export_options.py b/glue_ar/common/scatter_export_options.py
index 681e7e8..f276584 100644
--- a/glue_ar/common/scatter_export_options.py
+++ b/glue_ar/common/scatter_export_options.py
@@ -7,7 +7,14 @@
class ARVispyScatterExportOptions(State):
- resolution = RangedCallbackProperty(default=10, min_value=3, max_value=50, resolution=1)
+ resolution = RangedCallbackProperty(
+ default=10,
+ min_value=3,
+ max_value=50,
+ resolution=1,
+ docstring="Controls the resolution of the sphere meshes used for scatter points. "
+ "Higher means better resolution, but a larger filesize.",
+ )
class ARIpyvolumeScatterExportOptions(State):
diff --git a/glue_ar/common/tests/test_base_dialog.py b/glue_ar/common/tests/test_base_dialog.py
index 1ed3081..c6fea5f 100644
--- a/glue_ar/common/tests/test_base_dialog.py
+++ b/glue_ar/common/tests/test_base_dialog.py
@@ -9,9 +9,9 @@
class DummyState(State):
- cb_int = CallbackProperty(2)
- cb_float = CallbackProperty(0.7)
- cb_bool = CallbackProperty(False)
+ cb_int = CallbackProperty(2, docstring="Integer callback property")
+ cb_float = CallbackProperty(0.7, docstring="Float callback property")
+ cb_bool = CallbackProperty(False, docstring="Boolean callback property")
class BaseExportDialogTest:
diff --git a/glue_ar/common/volume_export_options.py b/glue_ar/common/volume_export_options.py
index 155bd55..eb19400 100644
--- a/glue_ar/common/volume_export_options.py
+++ b/glue_ar/common/volume_export_options.py
@@ -7,9 +7,28 @@
class ARIsosurfaceExportOptions(State):
- isosurface_count = RangedCallbackProperty(default=20, min_value=1, max_value=50)
+ isosurface_count = RangedCallbackProperty(
+ default=20,
+ min_value=1,
+ max_value=50,
+ docstring="The number of isosurfaces used in the export.",
+ )
class ARVoxelExportOptions(State):
- opacity_cutoff = RangedCallbackProperty(default=0.1, min_value=0.01, max_value=1, resolution=0.01)
- opacity_resolution = RangedCallbackProperty(default=0.02, min_value=0.005, max_value=1, resolution=0.005)
+ opacity_cutoff = RangedCallbackProperty(
+ default=0.1,
+ min_value=0.01,
+ max_value=1,
+ resolution=0.01,
+ docstring="The minimum opacity voxels to retain. Voxels with a lower opacity will be "
+ "omitted from the export.",
+ )
+ opacity_resolution = RangedCallbackProperty(
+ default=0.02,
+ min_value=0.005,
+ max_value=1,
+ resolution=0.005,
+ docstring="The resolution of the opacity in the exported figure. Opacity values will be "
+ "rounded to the nearest integer multiple of this value.",
+ )
diff --git a/glue_ar/jupyter/export_dialog.py b/glue_ar/jupyter/export_dialog.py
index e6f329c..80f0ecc 100644
--- a/glue_ar/jupyter/export_dialog.py
+++ b/glue_ar/jupyter/export_dialog.py
@@ -1,16 +1,16 @@
import ipyvuetify as v # noqa
from ipyvuetify.VuetifyTemplate import VuetifyTemplate
-from ipywidgets import DOMWidget, widget_serialization
+from ipywidgets import widget_serialization
import traitlets
-from typing import Callable, List, Optional
+from typing import Callable, Optional
-from echo import HasCallbackProperties
from glue.core.state_objects import State
from glue.viewers.common.viewer import Viewer
from glue_jupyter.link import link
from glue_jupyter.vuetify_helpers import link_glue_choices
from glue_ar.common.export_dialog_base import ARExportDialogBase
+from glue_ar.jupyter.widgets import widgets_for_callback_property
class JupyterARExportDialog(ARExportDialogBase, VuetifyTemplate):
@@ -31,7 +31,7 @@ class JupyterARExportDialog(ARExportDialogBase, VuetifyTemplate):
method_items = traitlets.List().tag(sync=True)
method_selected = traitlets.Int().tag(sync=True)
- layer_layout = traitlets.Instance(v.Container).tag(sync=True, **widget_serialization)
+ layer_layout = traitlets.Instance(v.Col).tag(sync=True, **widget_serialization)
has_layer_options = traitlets.Bool().tag(sync=True)
modelviewer = traitlets.Bool(True).tag(sync=True)
@@ -42,8 +42,9 @@ def __init__(self,
display: Optional[bool] = False,
on_cancel: Optional[Callable] = None,
on_export: Optional[Callable] = None):
+
ARExportDialogBase.__init__(self, viewer=viewer)
- self.layer_layout = v.Container()
+ self.layer_layout = v.Col()
VuetifyTemplate.__init__(self)
self._on_layer_change(self.state.layer)
@@ -65,12 +66,17 @@ def _update_layer_ui(self, state: State):
for widget in self.layer_layout.children:
widget.close()
- widgets = []
+ rows = []
+ input_widgets = []
+ self.layer_layout = v.Col()
for property, _ in state.iter_callback_properties():
name = self.display_name(property)
- widgets.extend(self.widgets_for_property(state, property, name))
- self.input_widgets = [w for w in widgets if isinstance(w, v.Slider)]
- self.layer_layout = v.Container(children=widgets, px_0=True, py_0=True)
+ widgets = widgets_for_callback_property(state, property, name)
+ input_widgets.extend(w for w in widgets if isinstance(w, v.Slider))
+ rows.append(v.Row(children=widgets, align="center"))
+
+ self.layer_layout.children = rows
+ self.input_widgets = input_widgets
self.has_layer_options = len(self.layer_layout.children) > 0
def _on_method_change(self, method_name: str):
@@ -84,37 +90,6 @@ def _on_filetype_change(self, filetype: str):
self.show_compression = gl
self.show_modelviewer = gl
- def widgets_for_property(self,
- instance: HasCallbackProperties,
- property: str,
- display_name: str) -> List[DOMWidget]:
-
- value = getattr(instance, property)
- t = type(value)
- if t is bool:
- widget = v.Checkbox(label=display_name)
- link((instance, property), (widget, 'value'))
- return [widget]
- elif t in (int, float):
- instance_type = type(instance)
- cb_property = getattr(instance_type, property)
- min = getattr(cb_property, 'min_value', 1 if t is int else 0.01)
- max = getattr(cb_property, 'max_value', 100 * min)
- step = getattr(cb_property, 'resolution', None)
- if step is None:
- step = 1 if t is int else 0.01
- widget = v.Slider(min=min,
- max=max,
- step=step,
- label=display_name,
- thumb_label=f"{value:g}")
- link((instance, property),
- (widget, 'v_model'))
-
- return [widget]
- else:
- return []
-
def vue_cancel_dialog(self, *args):
self.state_dictionary = {}
self.dialog_open = False
diff --git a/glue_ar/jupyter/tests/test_dialog.py b/glue_ar/jupyter/tests/test_dialog.py
index a778b85..9bca646 100644
--- a/glue_ar/jupyter/tests/test_dialog.py
+++ b/glue_ar/jupyter/tests/test_dialog.py
@@ -8,7 +8,6 @@
# We can't use the Jupyter vispy widget for these tests until
# https://github.com/glue-viz/glue-vispy-viewers/pull/388 is released
from glue_jupyter.ipyvolume.volume import IpyvolumeVolumeView
-from ipyvuetify import Checkbox, Slider
from glue_ar.common.tests.test_base_dialog import BaseExportDialogTest, DummyState
from glue_ar.jupyter.export_dialog import JupyterARExportDialog
@@ -89,30 +88,6 @@ def test_filetype_change(self):
assert self.dialog.show_compression
assert self.dialog.show_modelviewer
- def test_widgets_for_property(self):
- state = DummyState()
-
- int_widgets = self.dialog.widgets_for_property(state, "cb_int", "Int CB")
- assert len(int_widgets) == 1
- widget = int_widgets[0]
- assert isinstance(widget, Slider)
- assert widget.label == "Int CB"
- assert widget.v_model == 2
-
- float_widgets = self.dialog.widgets_for_property(state, "cb_float", "Float CB")
- assert len(float_widgets) == 1
- widget = float_widgets[0]
- assert isinstance(widget, Slider)
- assert widget.label == "Float CB"
- assert widget.v_model == 0.7
-
- bool_widgets = self.dialog.widgets_for_property(state, "cb_bool", "Bool CB")
- assert len(bool_widgets) == 1
- widget = bool_widgets[0]
- assert isinstance(widget, Checkbox)
- assert widget.label == "Bool CB"
- assert widget.value is False
-
def test_update_layer_ui(self):
state = DummyState()
self.dialog._update_layer_ui(state)
diff --git a/glue_ar/jupyter/tests/test_widgets.py b/glue_ar/jupyter/tests/test_widgets.py
new file mode 100644
index 0000000..5d897dd
--- /dev/null
+++ b/glue_ar/jupyter/tests/test_widgets.py
@@ -0,0 +1,100 @@
+from pytest import importorskip
+
+importorskip("glue_jupyter")
+
+from echo import CallbackProperty
+from ipyvuetify import Checkbox, Img, Slider, Tooltip
+
+from glue_ar.common.tests.test_base_dialog import DummyState
+from glue_ar.jupyter.widgets import boolean_callback_widgets, info_icon, \
+ info_tooltip, number_callback_widgets, \
+ widgets_for_callback_property
+
+
+def test_info_tooltip():
+ assert info_tooltip(DummyState.cb_int) == ["Integer callback property"]
+ assert info_tooltip(DummyState.cb_float) == ["Float callback property"]
+ assert info_tooltip(DummyState.cb_bool) == ["Boolean callback property"]
+
+
+def test_info_button():
+ state = DummyState()
+ for property in state.callback_properties():
+ cb_property: CallbackProperty = getattr(DummyState, property)
+ icon = info_icon(cb_property)
+ assert isinstance(icon, Tooltip)
+ assert len(icon.children) == 1
+ assert len(icon.v_slots) == 1
+ slot = icon.v_slots[0]
+ assert slot["name"] == "activator"
+ assert slot["variable"] == "tooltip"
+ assert len(slot["children"]) == 1
+ img = slot["children"][0]
+ assert isinstance(img, Img)
+
+
+def test_boolean_callback_widgets():
+ state = DummyState()
+ widgets = boolean_callback_widgets(state, "cb_bool", "Bool CB")
+ assert len(widgets) == 2
+ checkbox, icon = widgets
+
+ assert isinstance(checkbox, Checkbox)
+ assert checkbox.label == "Bool CB"
+
+ assert not checkbox.value
+ assert isinstance(icon, Tooltip)
+
+
+def test_integer_callback_widgets():
+ state = DummyState()
+ widgets = number_callback_widgets(state, "cb_int", "Int CB")
+ assert len(widgets) == 2
+ slider, icon = widgets
+
+ assert isinstance(slider, Slider)
+ assert slider.label == "Int CB"
+ assert slider.v_model == 2
+
+ assert isinstance(icon, Tooltip)
+
+
+def test_float_callback_widgets():
+ state = DummyState()
+ widgets = number_callback_widgets(state, "cb_float", "Float CB")
+ assert len(widgets) == 2
+ slider, icon = widgets
+
+ assert isinstance(slider, Slider)
+ assert slider.label == "Float CB"
+ assert slider.v_model == 0.7
+
+ assert isinstance(icon, Tooltip)
+
+
+def test_widgets_for_property():
+ state = DummyState()
+
+ int_widgets = widgets_for_callback_property(state, "cb_int", "Int CB")
+ assert len(int_widgets) == 2
+ slider, icon = int_widgets
+ assert isinstance(slider, Slider)
+ assert slider.label == "Int CB"
+ assert slider.v_model == 2
+ assert isinstance(icon, Tooltip)
+
+ float_widgets = widgets_for_callback_property(state, "cb_float", "Float CB")
+ assert len(float_widgets) == 2
+ slider, icon = float_widgets
+ assert isinstance(slider, Slider)
+ assert slider.label == "Float CB"
+ assert slider.v_model == 0.7
+ assert isinstance(icon, Tooltip)
+
+ bool_widgets = widgets_for_callback_property(state, "cb_bool", "Bool CB")
+ assert len(bool_widgets) == 2
+ checkbox, icon = bool_widgets
+ assert isinstance(checkbox, Checkbox)
+ assert checkbox.label == "Bool CB"
+ assert not checkbox.value
+ assert isinstance(icon, Tooltip)
diff --git a/glue_ar/jupyter/widgets.py b/glue_ar/jupyter/widgets.py
new file mode 100644
index 0000000..f3bef1e
--- /dev/null
+++ b/glue_ar/jupyter/widgets.py
@@ -0,0 +1,100 @@
+from os.path import join
+from typing import List, Tuple
+
+from echo import CallbackProperty, HasCallbackProperties
+from glue_jupyter.common.toolbar_vuetify import read_icon
+from glue_jupyter.link import link
+import ipyvuetify as v
+from ipywidgets import DOMWidget
+
+from glue_ar.utils import RESOURCES_DIR
+
+
+def info_tooltip(cb_property: CallbackProperty) -> List[str]:
+ if cb_property.__doc__:
+ return cb_property.__doc__.replace(". ", ".\n").split("\n")
+ else:
+ return []
+
+
+def info_icon(cb_property: CallbackProperty) -> v.Tooltip:
+ img_path = join(RESOURCES_DIR, "info.png")
+ icon_src = read_icon(img_path, "image/png")
+ tooltip_children = [v.Html(tag="div", children=[text]) for text in info_tooltip(cb_property) if text]
+ button = v.Tooltip(
+ top=True,
+ v_slots=[{
+ "name": "activator",
+ "variable": "tooltip",
+ "children": [
+ v.Img(v_on="tooltip.on", src=icon_src,
+ height=20, width=20,
+ max_width=20, max_height=20),
+ ],
+ }],
+ children=tooltip_children,
+ )
+ return button
+
+
+def boolean_callback_widgets(instance: HasCallbackProperties,
+ property: str,
+ display_name: str) -> Tuple[DOMWidget]:
+
+ instance_type = type(instance)
+ cb_property = getattr(instance_type, property)
+
+ checkbox = v.Checkbox(label=display_name)
+ link((instance, property), (checkbox, 'value'))
+
+ if cb_property.__doc__:
+ icon = info_icon(cb_property)
+ return (checkbox, icon)
+ else:
+ return (checkbox,)
+
+
+def number_callback_widgets(instance: HasCallbackProperties,
+ property: str,
+ display_name: str) -> Tuple[DOMWidget]:
+
+ value = getattr(instance, property)
+ instance_type = type(instance)
+ cb_property = getattr(instance_type, property)
+ t = type(value)
+
+ min = getattr(cb_property, 'min_value', 1 if t is int else 0.01)
+ max = getattr(cb_property, 'max_value', 100 * min)
+ step = getattr(cb_property, 'resolution', None)
+ if step is None:
+ step = 1 if t is int else 0.01
+ slider = v.Slider(
+ min=min,
+ max=max,
+ step=step,
+ label=display_name,
+ hide_details=True,
+ thumb_label=f"{value:g}",
+ )
+ link((instance, property),
+ (slider, 'v_model'))
+
+ if cb_property.__doc__:
+ icon = info_icon(cb_property)
+ return (slider, icon)
+ else:
+ return (slider,)
+
+
+def widgets_for_callback_property(
+ instance: HasCallbackProperties,
+ property: str,
+ display_name: str) -> Tuple[DOMWidget]:
+
+ t = type(getattr(instance, property))
+ if t is bool:
+ return boolean_callback_widgets(instance, property, display_name)
+ elif t in (int, float):
+ return number_callback_widgets(instance, property, display_name)
+ else:
+ raise ValueError("Unsupported callback property type!")
diff --git a/glue_ar/qt/export_dialog.py b/glue_ar/qt/export_dialog.py
index 7c9c5ba..444add6 100644
--- a/glue_ar/qt/export_dialog.py
+++ b/glue_ar/qt/export_dialog.py
@@ -1,16 +1,13 @@
-from math import floor, log
import os
-from typing import List
-from echo import HasCallbackProperties, add_callback
-from echo.core import remove_callback
-from echo.qt import autoconnect_callbacks_to_qt, connect_checkable_button, connect_value
+from echo.qt import autoconnect_callbacks_to_qt
from glue.core.state_objects import State
from glue_qt.utils import load_ui
from glue_ar.common.export_dialog_base import ARExportDialogBase
-from qtpy.QtCore import Qt
-from qtpy.QtWidgets import QCheckBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLayout, QSizePolicy, QSlider, QWidget
+from qtpy.QtWidgets import QDialog, QFormLayout, QHBoxLayout, QLayoutItem, QVBoxLayout, QLayout, QWidget
+
+from glue_ar.qt.widgets import widgets_for_callback_property
__all__ = ['QtARExportDialog']
@@ -32,64 +29,6 @@ def __init__(self, parent=None, viewer=None):
self.ui.button_cancel.clicked.connect(self.reject)
self.ui.button_ok.clicked.connect(self.accept)
- def _widgets_for_property(self,
- instance: HasCallbackProperties,
- property: str,
- display_name: str) -> List[QWidget]:
- value = getattr(instance, property)
- t = type(value)
- if t is bool:
- widget = QCheckBox()
- widget.setChecked(value)
- widget.setText(display_name)
- self._layer_connections.append(connect_checkable_button(instance, property, widget))
- return [widget]
- elif t in (int, float):
- label = QLabel()
- prompt = f"{display_name}:"
- label.setText(prompt)
- widget = QSlider()
- policy = QSizePolicy()
- policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding)
- policy.setVerticalPolicy(QSizePolicy.Policy.Fixed)
- widget.setOrientation(Qt.Orientation.Horizontal)
-
- widget.setSizePolicy(policy)
-
- value_label = QLabel()
- instance_type = type(instance)
- cb_property = getattr(instance_type, property)
- min = getattr(cb_property, 'min_value', 1 if t is int else 0.01)
- max = getattr(cb_property, 'max_value', 100 * min)
- step = getattr(cb_property, 'resolution', None)
- if step is None:
- step = 1 if t is int else 0.01
- places = -floor(log(step, 10))
-
- def update_label(value):
- value_label.setText(f"{value:.{places}f}")
-
- def remove_label_callback(widget, update_label=update_label):
- try:
- remove_callback(instance, property, update_label)
- except ValueError:
- pass
-
- def on_widget_destroyed(widget, cb=remove_label_callback):
- cb(widget)
-
- update_label(value)
- add_callback(instance, property, update_label)
- widget.destroyed.connect(on_widget_destroyed)
-
- steps = round((max - min) / step)
- widget.setMinimum(0)
- widget.setMaximum(steps)
- self._layer_connections.append(connect_value(instance, property, widget, value_range=(min, max)))
- return [label, widget, value_label]
- else:
- return []
-
def _clear_layout(self, layout: QLayout):
if layout is not None:
while layout.count():
@@ -119,16 +58,24 @@ def _on_layer_change(self, layer_name: str):
multiple_methods = len(self.state.method_helper.choices) > 1
self.ui.label_method.setVisible(multiple_methods)
self.ui.combosel_method.setVisible(multiple_methods)
+ self.ui.line_2.setVisible(multiple_methods)
def _update_layer_ui(self, state: State):
self._clear_layer_layout()
for property in state.callback_properties():
- row = QHBoxLayout()
+ row = QVBoxLayout()
name = self.display_name(property)
- widgets = self._widgets_for_property(state, property, name)
- for widget in widgets:
- row.addWidget(widget)
- self.ui.layer_layout.addRow(row)
+ widget_tuples, connection = widgets_for_callback_property(state, property, name)
+ self._layer_connections.append(connection)
+ for widgets in widget_tuples:
+ subrow = QHBoxLayout()
+ for widget in widgets:
+ if isinstance(widget, QWidget):
+ subrow.addWidget(widget)
+ elif isinstance(widget, QLayoutItem):
+ subrow.addItem(widget)
+ row.addLayout(subrow)
+ self.ui.layer_layout.addLayout(row)
def _on_filetype_change(self, filetype: str):
super()._on_filetype_change(filetype)
diff --git a/glue_ar/qt/export_dialog.ui b/glue_ar/qt/export_dialog.ui
index 01e98c8..323ea87 100644
--- a/glue_ar/qt/export_dialog.ui
+++ b/glue_ar/qt/export_dialog.ui
@@ -14,70 +14,6 @@
Export 3D File
- -
-
-
- <html><head/><body><p align="center"><span style=" font-weight:600;">Layer Options</span></p></body></html>
-
-
-
- -
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- -
-
-
- -
-
-
- Select export options
-
-
-
- -
-
-
- Filetype
-
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- <html><head/><body><p align="center"><span style=" font-weight:600;">File Options</span></p></body></html>
-
-
-
- -
-
-
- Export method:
-
-
-
- -
-
-
- Compression method
-
-
-
-
@@ -117,6 +53,60 @@
+ -
+
+
+ <html><head/><body><p align="center"><span style=" font-weight:600;">File Options</span></p></body></html>
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ -
+
+
+ -
+
+
+ <html><head/><body><p align="center"><span style=" font-weight:600;">Layer Options</span></p></body></html>
+
+
+
+ -
+
+
+ Compression method
+
+
+
+ -
+
+
+ Export method:
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ Select export options
+
+
+
+ -
+
+
-
@@ -127,6 +117,23 @@
+ -
+
+
+ Filetype
+
+
+
+ -
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
diff --git a/glue_ar/qt/tests/test_dialog.py b/glue_ar/qt/tests/test_dialog.py
index ad7557f..62b35d1 100644
--- a/glue_ar/qt/tests/test_dialog.py
+++ b/glue_ar/qt/tests/test_dialog.py
@@ -6,7 +6,6 @@
from glue_qt.app import GlueApplication
from glue_vispy_viewers.volume.qt.volume_viewer import VispyVolumeViewer
-from qtpy.QtWidgets import QCheckBox, QLabel, QSlider
from glue_ar.common.tests.test_base_dialog import BaseExportDialogTest, DummyState
from glue_ar.common.scatter_export_options import ARVispyScatterExportOptions
@@ -72,44 +71,14 @@ def test_filetype_change(self):
assert ui.combosel_compression.isVisible()
assert ui.label_compression_message.isVisible()
- def test_widgets_for_property(self):
- state = DummyState()
-
- int_widgets = self.dialog._widgets_for_property(state, "cb_int", "Int CB")
- assert len(int_widgets) == 3
- label, slider, value_label = int_widgets
- assert isinstance(label, QLabel)
- assert label.text() == "Int CB:"
- assert isinstance(slider, QSlider)
- assert slider.value() == 1 # 2 is the second (index 1) step value
- assert isinstance(value_label, QLabel)
- assert value_label.text() == "2"
-
- float_widgets = self.dialog._widgets_for_property(state, "cb_float", "Float CB")
- assert len(float_widgets) == 3
- label, slider, value_label = float_widgets
- assert isinstance(label, QLabel)
- assert label.text() == "Float CB:"
- assert isinstance(slider, QSlider)
- assert slider.value() == 69 # Another value -> index thing (see above comment)
- assert isinstance(value_label, QLabel)
- assert value_label.text() == "0.70"
-
- bool_widgets = self.dialog._widgets_for_property(state, "cb_bool", "Bool CB")
- assert len(bool_widgets) == 1
- box = bool_widgets[0]
- assert isinstance(box, QCheckBox)
- assert box.text() == "Bool CB"
- assert not box.isChecked()
-
def test_update_layer_ui(self):
state = DummyState()
self.dialog._update_layer_ui(state)
- assert self.dialog.ui.layer_layout.rowCount() == 3
+ assert self.dialog.ui.layer_layout.count() == 3
state = ARVispyScatterExportOptions()
self.dialog._update_layer_ui(state)
- assert self.dialog.ui.layer_layout.rowCount() == 1
+ assert self.dialog.ui.layer_layout.count() == 1
def test_clear_layout(self):
self.dialog._clear_layer_layout()
diff --git a/glue_ar/qt/tests/test_widgets.py b/glue_ar/qt/tests/test_widgets.py
new file mode 100644
index 0000000..278ada7
--- /dev/null
+++ b/glue_ar/qt/tests/test_widgets.py
@@ -0,0 +1,132 @@
+from pytest import importorskip
+
+importorskip("glue_qt")
+
+from echo import CallbackProperty
+from echo.qt import connect_checkable_button, connect_value
+from qtpy.QtWidgets import QPushButton, QSpacerItem, QCheckBox, QLabel, QSlider
+
+from glue_ar.common.tests.test_base_dialog import DummyState
+from glue_ar.qt.widgets import boolean_callback_widgets, horizontal_spacer, \
+ info_button, info_tooltip, widgets_for_callback_property
+
+
+def test_info_tooltip(qtbot):
+ assert info_tooltip(DummyState.cb_int) == "Integer callback property"
+ assert info_tooltip(DummyState.cb_float) == "Float callback property"
+ assert info_tooltip(DummyState.cb_bool) == "Boolean callback property"
+
+
+def test_horizontal_spacer(qtbot):
+ spacer = horizontal_spacer(width=60, height=80)
+ assert isinstance(spacer, QSpacerItem)
+
+
+def test_info_button(qtbot):
+ state = DummyState()
+ for property in state.callback_properties():
+ cb_property: CallbackProperty = getattr(DummyState, property)
+ button = info_button(cb_property)
+ assert isinstance(button, QPushButton)
+
+
+def test_boolean_callback_widgets(qtbot):
+ state = DummyState()
+
+ widget_tuples, connection = boolean_callback_widgets(state, "cb_bool", "Bool CB")
+ assert isinstance(connection, connect_checkable_button)
+
+ assert len(widget_tuples) == 1
+ box, spacer, info_button = widget_tuples[0]
+ assert isinstance(box, QCheckBox)
+ assert box.text() == "Bool CB"
+ assert not box.isChecked()
+ assert isinstance(spacer, QSpacerItem)
+ assert isinstance(info_button, QPushButton)
+
+
+def test_integer_callback_widgets(qtbot):
+ state = DummyState()
+ widget_rows, connection = widgets_for_callback_property(state, "cb_int", "Int CB")
+
+ assert isinstance(connection, connect_value)
+
+ assert len(widget_rows) == 2
+ label, spacer, info_button = widget_rows[0]
+ assert isinstance(label, QLabel)
+ assert label.text() == "Int CB:"
+ assert isinstance(spacer, QSpacerItem)
+ assert isinstance(info_button, QPushButton)
+
+ slider, value_label = widget_rows[1]
+ assert isinstance(slider, QSlider)
+ assert slider.value() == 1 # 2 is the second (index 1) step value
+ assert isinstance(value_label, QLabel)
+ assert value_label.text() == "2"
+
+
+def test_float_callback_widgets():
+ state = DummyState()
+ widget_rows, connection = widgets_for_callback_property(state, "cb_float", "Float CB")
+
+ assert isinstance(connection, connect_value)
+ assert len(widget_rows) == 2
+
+ label, spacer, info_button = widget_rows[0]
+ assert isinstance(label, QLabel)
+ assert label.text() == "Float CB:"
+ assert isinstance(spacer, QSpacerItem)
+ assert isinstance(info_button, QPushButton)
+
+ slider, value_label = widget_rows[1]
+ assert isinstance(slider, QSlider)
+ assert slider.value() == 69 # Another value -> index thing (see above comment)
+ assert isinstance(value_label, QLabel)
+ assert value_label.text() == "0.70"
+
+
+def test_widgets_for_callback_property(qtbot):
+ state = DummyState()
+
+ int_widget_rows, connection = widgets_for_callback_property(state, "cb_int", "Int CB")
+ assert isinstance(connection, connect_value)
+ assert len(int_widget_rows) == 2
+
+ label, spacer, info_button = int_widget_rows[0]
+ assert isinstance(label, QLabel)
+ assert label.text() == "Int CB:"
+ assert isinstance(spacer, QSpacerItem)
+ assert isinstance(info_button, QPushButton)
+
+ slider, value_label = int_widget_rows[1]
+ assert isinstance(slider, QSlider)
+ assert slider.value() == 1 # 2 is the second (index 1) step value
+ assert isinstance(value_label, QLabel)
+ assert value_label.text() == "2"
+
+ float_widget_rows, connection = widgets_for_callback_property(state, "cb_float", "Float CB")
+ assert isinstance(connection, connect_value)
+ assert len(float_widget_rows) == 2
+
+ label, spacer, info_button = float_widget_rows[0]
+ assert isinstance(label, QLabel)
+ assert label.text() == "Float CB:"
+ assert isinstance(spacer, QSpacerItem)
+ assert isinstance(info_button, QPushButton)
+
+ slider, value_label = float_widget_rows[1]
+ assert isinstance(slider, QSlider)
+ assert slider.value() == 69 # Another value -> index thing (see above comment)
+ assert isinstance(value_label, QLabel)
+ assert value_label.text() == "0.70"
+
+ bool_widget_rows, connection = widgets_for_callback_property(state, "cb_bool", "Bool CB")
+ assert isinstance(connection, connect_checkable_button)
+ assert len(bool_widget_rows) == 1
+
+ box, spacer, info_button = bool_widget_rows[0]
+ assert isinstance(box, QCheckBox)
+ assert box.text() == "Bool CB"
+ assert not box.isChecked()
+ assert isinstance(spacer, QSpacerItem)
+ assert isinstance(info_button, QPushButton)
diff --git a/glue_ar/qt/widgets.py b/glue_ar/qt/widgets.py
new file mode 100644
index 0000000..3326004
--- /dev/null
+++ b/glue_ar/qt/widgets.py
@@ -0,0 +1,136 @@
+from math import floor, log
+from os.path import join
+from typing import Tuple
+
+from echo import CallbackProperty, HasCallbackProperties, add_callback, remove_callback
+from echo.qt import BaseConnection, connect_checkable_button, connect_value
+from qtpy.QtGui import QCursor, QEnterEvent, QIcon
+from qtpy.QtCore import Qt, QEvent
+from qtpy.QtWidgets import QCheckBox, QPushButton, QSpacerItem, QToolTip, QLabel, QSizePolicy, QSlider, QWidget
+
+from glue_ar.utils import RESOURCES_DIR
+
+
+def info_tooltip(cb_property: CallbackProperty) -> str:
+ return f"{cb_property.__doc__ or ''}"
+
+
+def info_enter_event_handler(_event: QEnterEvent, cb_property: CallbackProperty):
+ # Make the tooltip be rich text so that it will line wrap
+ QToolTip.showText(QCursor.pos(), info_tooltip(cb_property))
+
+
+def info_leave_event_handler(_event: QEvent):
+ QToolTip.hideText()
+
+
+def horizontal_spacer(width: int = 40, height: int = 20) -> QSpacerItem:
+ return QSpacerItem(width, height, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
+
+
+def info_button(cb_property: CallbackProperty) -> QPushButton:
+ button = QPushButton()
+ button.setCheckable(False)
+ button.setFlat(True)
+ icon_path = join(RESOURCES_DIR, "info.png")
+ icon = QIcon(icon_path)
+ button.setIcon(icon)
+ button.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum))
+
+ # We want the tooltip to show immediately, rather than have a delay
+ if cb_property.__doc__:
+ button.enterEvent = lambda event: info_enter_event_handler(event, cb_property=cb_property)
+ button.leaveEvent = info_leave_event_handler
+
+ return button
+
+
+def boolean_callback_widgets(instance: HasCallbackProperties,
+ property: str,
+ display_name: str) -> Tuple[Tuple[Tuple[QWidget]], connect_checkable_button]:
+
+ value = getattr(instance, property)
+ instance_type = type(instance)
+ cb_property: CallbackProperty = getattr(instance_type, property)
+
+ checkbox = QCheckBox()
+ checkbox.setChecked(value)
+ checkbox.setText(display_name)
+ connection = connect_checkable_button(instance, property, checkbox)
+ if cb_property.__doc__:
+ spacer = horizontal_spacer(width=40, height=20)
+ button = info_button(cb_property)
+ return ((checkbox, spacer, button),), connection
+ else:
+ return ((checkbox,),), connection
+
+
+def number_callback_widgets(instance: HasCallbackProperties,
+ property: str,
+ display_name: str) -> Tuple[Tuple[Tuple[QWidget]], connect_value]:
+
+ value = getattr(instance, property)
+ instance_type = type(instance)
+ cb_property: CallbackProperty = getattr(instance_type, property)
+ t = type(value)
+
+ label = QLabel()
+ prompt = f"{display_name}:"
+ label.setText(prompt)
+
+ slider = QSlider()
+ policy = QSizePolicy()
+ policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding)
+ policy.setVerticalPolicy(QSizePolicy.Policy.Fixed)
+ slider.setOrientation(Qt.Orientation.Horizontal)
+ slider.setSizePolicy(policy)
+
+ value_label = QLabel()
+ min = getattr(cb_property, 'min_value', 1 if t is int else 0.01)
+ max = getattr(cb_property, 'max_value', 100 * min)
+ step = getattr(cb_property, 'resolution', None)
+ if step is None:
+ step = 1 if t is int else 0.01
+ places = -floor(log(step, 10))
+
+ def update_label(value):
+ value_label.setText(f"{value:.{places}f}")
+
+ def remove_label_callback(widget, update_label=update_label):
+ try:
+ remove_callback(instance, property, update_label)
+ except ValueError:
+ pass
+
+ def on_widget_destroyed(widget, cb=remove_label_callback):
+ cb(widget)
+
+ update_label(value)
+ add_callback(instance, property, update_label)
+ slider.destroyed.connect(on_widget_destroyed)
+
+ steps = round((max - min) / step)
+ slider.setMinimum(0)
+ slider.setMaximum(steps)
+ connection = connect_value(instance, property, slider, value_range=(min, max))
+
+ value_widgets = (slider, value_label)
+ if cb_property.__doc__:
+ button = info_button(cb_property)
+ spacer = horizontal_spacer(width=40, height=20)
+ return ((label, spacer, button), value_widgets), connection
+ else:
+ return ((label,), value_widgets), connection
+
+
+def widgets_for_callback_property(instance: HasCallbackProperties,
+ property: str,
+ display_name: str) -> Tuple[Tuple[Tuple[QWidget]], BaseConnection]:
+
+ t = type(getattr(instance, property))
+ if t is bool:
+ return boolean_callback_widgets(instance, property, display_name)
+ elif t in (int, float):
+ return number_callback_widgets(instance, property, display_name)
+ else:
+ raise ValueError("Unsupported callback property type!")
diff --git a/glue_ar/resources/info.png b/glue_ar/resources/info.png
new file mode 100644
index 0000000..9e078d8
Binary files /dev/null and b/glue_ar/resources/info.png differ
diff --git a/glue_ar/resources/info.svg b/glue_ar/resources/info.svg
new file mode 100644
index 0000000..42037e2
--- /dev/null
+++ b/glue_ar/resources/info.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tox.ini b/tox.ini
index 46b50dc..b9cc6c0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -13,6 +13,8 @@ extras =
qt: qt
jupyter: jupyter
commands =
+ qt: pip install pytest-qt
+ all: pip install pytest-qt
test: pip freeze
test: pytest --pyargs glue_ar --cov glue_ar {posargs}