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}