Skip to content

Commit b393238

Browse files
authored
feat: add widget visibility options to ArrayViewerModel (#139)
* feat: add set_options for ArrayViewer * undo lock * move to model * fix tests * use show
1 parent 110dd08 commit b393238

13 files changed

+258
-63
lines changed

src/ndv/controllers/_array_viewer.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ def _request_data(self) -> None:
475475
future.add_done_callback(self._on_data_response_ready)
476476

477477
if self._futures:
478-
self._view.set_progress_spinner_visible(True)
478+
self._viewer_model.show_progress_spinner = True
479479

480480
def _is_idle(self) -> bool:
481481
"""Return True if no futures are running. Used for testing, and debugging."""
@@ -490,7 +490,7 @@ def _cancel_futures(self) -> None:
490490
while self._futures:
491491
self._futures.pop().cancel()
492492
self._futures.clear()
493-
self._view.set_progress_spinner_visible(False)
493+
self._viewer_model.show_progress_spinner = False
494494

495495
@_app.ensure_main_thread
496496
def _on_data_response_ready(self, future: Future[DataResponse]) -> None:
@@ -499,7 +499,7 @@ def _on_data_response_ready(self, future: Future[DataResponse]) -> None:
499499
# which will prevent the widget from being garbage collected if the future
500500
self._futures.discard(future)
501501
if not self._futures:
502-
self._view.set_progress_spinner_visible(False)
502+
self._viewer_model.show_progress_spinner = False
503503

504504
if future.cancelled():
505505
return

src/ndv/models/_viewer_model.py

+38
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from enum import Enum, auto
2+
from typing import TYPE_CHECKING
23

34
from ndv.models._base_model import NDVModel
45

6+
if TYPE_CHECKING:
7+
from psygnal import Signal, SignalGroup
8+
59

610
class InteractionMode(Enum):
711
"""An enum defining graphical interaction mechanisms with an array Viewer."""
@@ -22,6 +26,40 @@ class ArrayViewerModel(NDVModel):
2226
----------
2327
interaction_mode : InteractionMode
2428
Describes the current interaction mode of the Viewer.
29+
show_3d_button : bool, optional
30+
Whether to show the 3D button, by default True.
31+
show_histogram_button : bool, optional
32+
Whether to show the histogram button, by default True.
33+
show_reset_zoom_button : bool, optional
34+
Whether to show the reset zoom button, by default True.
35+
show_roi_button : bool, optional
36+
Whether to show the ROI button, by default True.
37+
show_channel_mode_selector : bool, optional
38+
Whether to show the channel mode selector, by default True.
39+
show_progress_spinner : bool, optional
40+
Whether to show the progress spinner, by default
2541
"""
2642

2743
interaction_mode: InteractionMode = InteractionMode.PAN_ZOOM
44+
show_3d_button: bool = True
45+
show_histogram_button: bool = True
46+
show_reset_zoom_button: bool = True
47+
show_roi_button: bool = True
48+
show_channel_mode_selector: bool = True
49+
show_progress_spinner: bool = False
50+
51+
if TYPE_CHECKING:
52+
# just to make IDE autocomplete better
53+
# it's still hard to indicate dynamic members in the events group
54+
class ArrayViewerModelEvents(SignalGroup):
55+
"""Signal group for ArrayViewerModel."""
56+
57+
interaction_mode = Signal(InteractionMode, InteractionMode)
58+
show_3d_button = Signal(bool, bool)
59+
show_histogram_button = Signal(bool, bool)
60+
show_reset_zoom_button = Signal(bool, bool)
61+
show_roi_button = Signal(bool, bool)
62+
show_channel_mode_selector = Signal(bool, bool)
63+
show_progress_spinner = Signal(bool, bool)
64+
65+
events: ArrayViewerModelEvents = ArrayViewerModelEvents() # type: ignore

src/ndv/views/_jupyter/_array_view.py

+22-10
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from collections.abc import Container, Hashable, Iterator, Mapping, Sequence
1717

1818
import cmap
19+
from psygnal import EmissionInfo
1920
from traitlets import HasTraits
2021
from vispy.app.backends import _jupyter_rfb
2122

@@ -156,7 +157,7 @@ def __init__(
156157
viewer_model: ArrayViewerModel,
157158
) -> None:
158159
self._viewer_model = viewer_model
159-
self._viewer_model.events.interaction_mode.connect(self._on_model_mode_changed)
160+
self._viewer_model.events.connect(self._on_viewer_model_event)
160161
# WIDGETS
161162
self._data_model = data_model
162163
self._canvas_widget = canvas_widget
@@ -352,13 +353,6 @@ def _on_add_roi_button_toggle(self, change: dict[str, Any]) -> None:
352353
InteractionMode.CREATE_ROI if change["new"] else InteractionMode.PAN_ZOOM
353354
)
354355

355-
def _on_model_mode_changed(
356-
self, new: InteractionMode, old: InteractionMode
357-
) -> None:
358-
# If leaving CanvasMode.CREATE_ROI, uncheck the ROI button
359-
if old == InteractionMode.CREATE_ROI:
360-
self._add_roi_btn.value = False
361-
362356
def add_histogram(self, widget: Any) -> None:
363357
"""Add a histogram widget to the viewer."""
364358
warnings.warn("Histograms are not supported in Jupyter frontend", stacklevel=2)
@@ -409,5 +403,23 @@ def _on_reset_zoom_clicked(self, change: dict[str, Any]) -> None:
409403
def close(self) -> None:
410404
self.layout.close()
411405

412-
def set_progress_spinner_visible(self, visible: bool) -> None:
413-
self._progress_spinner.layout.display = "flex" if visible else "none"
406+
def _on_viewer_model_event(self, info: EmissionInfo) -> None:
407+
sig_name = info.signal.name
408+
value = info.args[0]
409+
if sig_name == "show_progress_spinner":
410+
self._progress_spinner.layout.display = "flex" if value else "none"
411+
elif sig_name == "interaction_mode":
412+
# If leaving CanvasMode.CREATE_ROI, uncheck the ROI button
413+
new, old = info.args
414+
if old == InteractionMode.CREATE_ROI:
415+
self._add_roi_btn.value = False
416+
elif sig_name == "show_histogram_button":
417+
...
418+
elif sig_name == "show_roi_button":
419+
self._add_roi_btn.layout.display = "flex" if value else "none"
420+
elif sig_name == "show_channel_mode_selector":
421+
self._channel_mode_combo.layout.display = "flex" if value else "none"
422+
elif sig_name == "show_reset_zoom_button":
423+
self._reset_zoom_btn.layout.display = "flex" if value else "none"
424+
elif sig_name == "show_3d_button":
425+
self._ndims_btn.layout.display = "flex" if value else "none"

src/ndv/views/_qt/_array_view.py

+24-11
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from collections.abc import Container, Hashable, Mapping, Sequence
3636

3737
import cmap
38+
from psygnal import EmissionInfo
3839
from qtpy.QtGui import QIcon
3940

4041
from ndv._types import AxisKey
@@ -451,7 +452,8 @@ def __init__(
451452
self._qwidget = qwdg = _QArrayViewer(canvas_widget)
452453
qwdg.histogram_btn.clicked.connect(self._on_add_histogram_clicked)
453454
qwdg.add_roi_btn.toggled.connect(self._on_add_roi_clicked)
454-
self._viewer_model.events.interaction_mode.connect(self._on_model_mode_changed)
455+
456+
self._viewer_model.events.connect(self._on_viewer_model_event)
455457

456458
# TODO: use emit_fast
457459
qwdg.dims_sliders.currentIndexChanged.connect(self.currentIndexChanged.emit)
@@ -484,13 +486,6 @@ def _on_add_histogram_clicked(self) -> None:
484486
else:
485487
self.histogramRequested.emit()
486488

487-
def _on_model_mode_changed(
488-
self, new: InteractionMode, old: InteractionMode
489-
) -> None:
490-
# If leaving CanvasMode.CREATE_ROI, uncheck the ROI button
491-
if old == InteractionMode.CREATE_ROI:
492-
self._qwidget.add_roi_btn.setChecked(False)
493-
494489
def add_histogram(self, widget: QWidget) -> None:
495490
if hasattr(self, "_hist"):
496491
raise RuntimeError("Only one histogram can be added at a time")
@@ -565,10 +560,28 @@ def close(self) -> None:
565560
def frontend_widget(self) -> QWidget:
566561
return self._qwidget
567562

568-
def set_progress_spinner_visible(self, visible: bool) -> None:
569-
self._qwidget._progress_spinner.setVisible(visible)
570-
571563
def _on_add_roi_clicked(self, checked: bool) -> None:
572564
self._viewer_model.interaction_mode = (
573565
InteractionMode.CREATE_ROI if checked else InteractionMode.PAN_ZOOM
574566
)
567+
568+
def _on_viewer_model_event(self, info: EmissionInfo) -> None:
569+
sig_name = info.signal.name
570+
value = info.args[0]
571+
if sig_name == "show_progress_spinner":
572+
self._qwidget._progress_spinner.setVisible(value)
573+
if sig_name == "interaction_mode":
574+
# If leaving CanvasMode.CREATE_ROI, uncheck the ROI button
575+
new, old = info.args
576+
if old == InteractionMode.CREATE_ROI:
577+
self._qwidget.add_roi_btn.setChecked(False)
578+
elif sig_name == "show_histogram_button":
579+
self._qwidget.histogram_btn.setVisible(value)
580+
elif sig_name == "show_roi_button":
581+
self._qwidget.add_roi_btn.setVisible(value)
582+
elif sig_name == "show_channel_mode_selector":
583+
self._qwidget.channel_mode_combo.setVisible(value)
584+
elif sig_name == "show_reset_zoom_button":
585+
self._qwidget.set_range_btn.setVisible(value)
586+
elif sig_name == "show_3d_button":
587+
self._qwidget.ndims_btn.setVisible(value)

src/ndv/views/_wx/_array_view.py

+36-17
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import wx
88
import wx.adv
99
import wx.lib.newevent
10-
from psygnal import Signal
10+
from psygnal import EmissionInfo, Signal
1111

1212
from ndv.models._array_display_model import ChannelMode
1313
from ndv.models._lut_model import ClimPolicy, ClimsManual, ClimsMinMax
@@ -86,7 +86,6 @@ class WxLutView(LutView):
8686
def __init__(self, parent: wx.Window) -> None:
8787
super().__init__()
8888
self._wxwidget = wdg = _WxLUTWidget(parent)
89-
# TODO: use emit_fast
9089
wdg.visible.Bind(wx.EVT_CHECKBOX, self._on_visible_changed)
9190
wdg.cmap.Bind(wx.EVT_COMBOBOX, self._on_cmap_changed)
9291
wdg.clims.Bind(wx.EVT_SLIDER, self._on_clims_changed)
@@ -252,7 +251,7 @@ def __init__(self, canvas_widget: wx.Window, parent: wx.Window = None):
252251
)
253252

254253
# Reset zoom button
255-
self.reset_zoom_btn = wx.Button(self, label="Reset Zoom")
254+
self.set_range_btn = wx.Button(self, label="Reset Zoom")
256255

257256
# 3d view button
258257
self.ndims_btn = wx.ToggleButton(self, label="3D")
@@ -263,12 +262,12 @@ def __init__(self, canvas_widget: wx.Window, parent: wx.Window = None):
263262
# LUT layout (simple vertical grouping for LUT widgets)
264263
self.luts = wx.BoxSizer(wx.VERTICAL)
265264

266-
btns = wx.BoxSizer(wx.HORIZONTAL)
267-
btns.AddStretchSpacer()
268-
btns.Add(self.channel_mode_combo, 0, wx.ALL, 5)
269-
btns.Add(self.reset_zoom_btn, 0, wx.ALL, 5)
270-
btns.Add(self.ndims_btn, 0, wx.ALL, 5)
271-
btns.Add(self.add_roi_btn, 0, wx.ALL, 5)
265+
self._btns = wx.BoxSizer(wx.HORIZONTAL)
266+
self._btns.AddStretchSpacer()
267+
self._btns.Add(self.channel_mode_combo, 0, wx.ALL, 5)
268+
self._btns.Add(self.set_range_btn, 0, wx.ALL, 5)
269+
self._btns.Add(self.ndims_btn, 0, wx.ALL, 5)
270+
self._btns.Add(self.add_roi_btn, 0, wx.ALL, 5)
272271

273272
self._top_info = top_info = wx.BoxSizer(wx.HORIZONTAL)
274273
top_info.Add(self._data_info_label, 0, wx.EXPAND | wx.BOTTOM, 0)
@@ -281,7 +280,7 @@ def __init__(self, canvas_widget: wx.Window, parent: wx.Window = None):
281280
inner.Add(self._hover_info_label, 0, wx.EXPAND | wx.BOTTOM)
282281
inner.Add(self.dims_sliders, 0, wx.EXPAND | wx.BOTTOM)
283282
inner.Add(self.luts, 0, wx.EXPAND)
284-
inner.Add(btns, 0, wx.EXPAND)
283+
inner.Add(self._btns, 0, wx.EXPAND)
285284

286285
outer = wx.BoxSizer(wx.VERTICAL)
287286
outer.Add(inner, 1, wx.EXPAND | wx.ALL, 10)
@@ -300,13 +299,13 @@ def __init__(
300299
) -> None:
301300
self._data_model = data_model
302301
self._viewer_model = viewer_model
302+
self._viewer_model.events.connect(self._on_viewer_model_event)
303303
self._wxwidget = wdg = _WxArrayViewer(canvas_widget, parent)
304304
self._visible_axes: Sequence[AxisKey] = []
305305

306-
# TODO: use emit_fast
307306
wdg.dims_sliders.currentIndexChanged.connect(self.currentIndexChanged.emit)
308307
wdg.channel_mode_combo.Bind(wx.EVT_COMBOBOX, self._on_channel_mode_changed)
309-
wdg.reset_zoom_btn.Bind(wx.EVT_BUTTON, self._on_reset_zoom_clicked)
308+
wdg.set_range_btn.Bind(wx.EVT_BUTTON, self._on_reset_zoom_clicked)
310309
wdg.ndims_btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_ndims_toggled)
311310
wdg.add_roi_btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_add_roi_toggled)
312311

@@ -397,9 +396,29 @@ def set_visible(self, visible: bool) -> None:
397396
def close(self) -> None:
398397
self._wxwidget.Close()
399398

400-
def set_progress_spinner_visible(self, visible: bool) -> None:
401-
if visible:
402-
self._wxwidget._progress_spinner.Show()
399+
def _on_viewer_model_event(self, info: EmissionInfo) -> None:
400+
sig_name = info.signal.name
401+
value = info.args[0]
402+
if sig_name == "show_progress_spinner":
403+
self._wxwidget._progress_spinner.Show(value)
403404
self._wxwidget._top_info.Layout()
404-
else:
405-
self._wxwidget._progress_spinner.Hide()
405+
elif sig_name == "interaction_mode":
406+
# If leaving CanvasMode.CREATE_ROI, uncheck the ROI button
407+
new, old = info.args
408+
if old == InteractionMode.CREATE_ROI:
409+
self._wxwidget.add_roi_btn.SetValue(False)
410+
elif sig_name == "show_histogram_button":
411+
# _set_visible(self._wxwidget.histogram_btn, value)
412+
...
413+
elif sig_name == "show_roi_button":
414+
self._wxwidget.add_roi_btn.Show(value)
415+
self._wxwidget._btns.Layout()
416+
elif sig_name == "show_channel_mode_selector":
417+
self._wxwidget.channel_mode_combo.Show(value)
418+
self._wxwidget._btns.Layout()
419+
elif sig_name == "show_reset_zoom_button":
420+
self._wxwidget.set_range_btn.Show(value)
421+
self._wxwidget._btns.Layout()
422+
elif sig_name == "show_3d_button":
423+
self._wxwidget.ndims_btn.Show(value)
424+
self._wxwidget._btns.Layout()

src/ndv/views/bases/_array_view.py

-3
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,3 @@ def add_histogram(self, widget: Any) -> None:
7474

7575
def remove_histogram(self, widget: Any) -> None:
7676
raise NotImplementedError
77-
78-
def set_progress_spinner_visible(self, visible: bool) -> None:
79-
return

tests/conftest.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def asyncio_app() -> Iterator[AbstractEventLoop]:
3333
loop.close()
3434

3535

36-
@pytest.fixture
36+
@pytest.fixture(scope="session")
3737
def wxapp() -> Iterator[wx.App]:
3838
import wx
3939

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
import ipywidgets
4+
from pytest import fixture
5+
6+
from ndv.models._data_display_model import _ArrayDataDisplayModel
7+
from ndv.models._viewer_model import ArrayViewerModel
8+
from ndv.views._jupyter._array_view import JupyterArrayView
9+
10+
11+
@fixture
12+
def viewer() -> JupyterArrayView:
13+
return JupyterArrayView(
14+
ipywidgets.DOMWidget(), _ArrayDataDisplayModel(), ArrayViewerModel()
15+
)
16+
17+
18+
def test_array_options(viewer: JupyterArrayView) -> None:
19+
assert viewer._ndims_btn.layout.display is None
20+
viewer._viewer_model.show_3d_button = False
21+
assert viewer._ndims_btn.layout.display == "none"
22+
23+
assert viewer._reset_zoom_btn.layout.display is None
24+
viewer._viewer_model.show_reset_zoom_button = False
25+
assert viewer._reset_zoom_btn.layout.display == "none"
26+
27+
assert viewer._channel_mode_combo.layout.display is None
28+
viewer._viewer_model.show_channel_mode_selector = False
29+
assert viewer._channel_mode_combo.layout.display == "none"
30+
31+
assert viewer._add_roi_btn.layout.display is None
32+
viewer._viewer_model.show_roi_button = False
33+
assert viewer._add_roi_btn.layout.display == "none"

tests/views/_qt/conftest.py

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from __future__ import annotations
2+
3+
from pytest import fixture
4+
5+
from ndv.views._qt._app import QtAppWrap
6+
7+
8+
@fixture(autouse=True)
9+
def init_provider() -> None:
10+
provider = QtAppWrap()
11+
provider.create_app()

0 commit comments

Comments
 (0)