Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions datalab/config/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,17 @@ def __init__(self) -> None:
expected_type=bool,
description="If True, hide the application list of the catalog.",
)
self.instrument_new_group = TypedOptionField(
self,
"instrument_new_group",
category="main",
default={},
expected_type=dict,
description=(
"State of the 'New group' option of each instrument window, chosen "
"by the user (key: 'plugin_id/tool_id')."
),
)
self.v020_plugins_warning_ignore = TypedOptionField(
self,
"v020_plugins_warning_ignore",
Expand Down
6 changes: 5 additions & 1 deletion datalab/gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,11 @@ def open_plugin_instrument(
if worker is None:
worker = self.__instrument_workers[key] = SerialWorker()
window = InstrumentWindow(
self, plugin.get_instrument(tool.id), tool.title, worker
self,
plugin.get_instrument(tool.id),
tool.title,
worker,
option_key=f"{plugin.plugin_id}/{tool.id}",
)
window.finished.connect(
functools.partial(self.__forget_instrument_window, key)
Expand Down
91 changes: 69 additions & 22 deletions datalab/gui/plugins/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from sigimax.adapters_plotpy.objects.signal import CURVESTYLES

from datalab.adapters_plotpy import create_adapter_from_object
from datalab.config import _
from datalab.config import Conf, _
from datalab.objectmodel import get_uuid

if TYPE_CHECKING:
Expand Down Expand Up @@ -94,12 +94,13 @@ class InstrumentWindow(QW.QDialog):
"""Live view of a plugin instrument, next to its settings.

The view is refreshed when settings change, and periodically in live mode.
Each acquisition is added to the workspace in a new group. The instrument is
opened with the window and closed with it; if opening it fails, the reason
is shown, and it is opened again before the next call. Its methods are
called one at a time in a worker thread: a request made while a call is
running is done next (an acquisition before a refresh, repeated refreshes
only once).
Each acquisition is added to the workspace in a new group or, when the
**New group** option is off, in the current group of the panel. The
instrument is opened with the window and closed with it; if opening it
fails, the reason is shown, and it is opened again before the next call. Its
methods are called one at a time in a worker thread: a request made while a
call is running is done next (an acquisition before a refresh, repeated
refreshes only once).

Args:
main: DataLab main window
Expand All @@ -109,6 +110,9 @@ class InstrumentWindow(QW.QDialog):
successive windows of the instrument so that a window opened again runs
its calls after those of the closed one (default: a worker of its own,
stopped when the window is closed)
option_key: key under which the state of the **New group** option is
remembered in the configuration (default: not remembered, the option
starts from :attr:`PluginInstrument.new_group`)
"""

#: Delay between a settings change and the refresh of the view (ms)
Expand All @@ -125,11 +129,13 @@ def __init__(
instrument: PluginInstrument,
title: str,
executor: SerialWorker | None = None,
option_key: str | None = None,
) -> None:
super().__init__(main)
win32_fix_title_bar_background(self)
self.main = main
self.instrument = instrument
self.option_key = option_key
self.setWindowTitle(title.rstrip(".…"))
self.setModal(False)
self.plotwidget: PlotWidget | None = None
Expand Down Expand Up @@ -180,10 +186,18 @@ def __init__(
self.live_button.toggled.connect(self.set_live)
commands.addWidget(self.live_button)
commands.addStretch()
self.acquire_button = QW.QPushButton(get_icon("record.svg"), _("Acquire"))
self.acquire_button.setToolTip(
_("Add an acquisition to the workspace, in a new group")
self.new_group_box = QW.QCheckBox(_("New group"))
self.new_group_box.setToolTip(
_(
"Add each acquisition to a new group, "
"otherwise to the current group of the panel"
)
)
self.new_group_box.setChecked(self._stored_new_group())
self.new_group_box.toggled.connect(self._store_new_group)
commands.addWidget(self.new_group_box)
self.acquire_button = QW.QPushButton(get_icon("record.svg"), _("Acquire"))
self.acquire_button.setToolTip(_("Add an acquisition to the workspace"))
self.acquire_button.clicked.connect(self.acquire)
commands.addWidget(self.acquire_button)
settings_layout.addLayout(commands)
Expand Down Expand Up @@ -348,6 +362,21 @@ def set_live(self, live: bool) -> None:
else:
self._live_timer.stop()

def _stored_new_group(self) -> bool:
"""Return the remembered state of the **New group** option, or the
default state set by the instrument."""
default = bool(self.instrument.new_group)
if self.option_key is None:
return default
return bool(Conf.instrument_new_group.get().get(self.option_key, default))

def _store_new_group(self, new_group: bool) -> None:
"""Remember the state of the **New group** option chosen by the user."""
if self.option_key is not None:
states = dict(Conf.instrument_new_group.get())
states[self.option_key] = bool(new_group)
Conf.instrument_new_group.set(states)

def stop(self) -> None:
"""Stop refreshing the view and close the instrument."""
self._refresh_timer.stop()
Expand Down Expand Up @@ -449,36 +478,54 @@ def render(self, frame: InstrumentFrame) -> None:
plot.replot()

def acquire(self) -> None:
"""Add an acquisition to the workspace, in a new group."""
"""Add an acquisition to the workspace, in a new group or in the current
group of the panel, depending on the **New group** option."""
self._request("acquire")

def _add_acquired(self, acquisition: InstrumentAcquisition) -> None:
"""Add the objects acquired by the instrument to the workspace."""
try:
self.add_acquisition(acquisition)
group_title = self.add_acquisition(acquisition)
except Exception as exc: # pylint: disable=broad-except
self.status_label.setText(str(exc))
return
self.status_label.setText(
_("%d objects added to group '%s'")
% (len(acquisition.objects), acquisition.group_title)
% (len(acquisition.objects), group_title)
)

def add_acquisition(self, acquisition: InstrumentAcquisition) -> None:
"""Add acquired objects to their panel, in a new group."""
def add_acquisition(self, acquisition: InstrumentAcquisition) -> str:
"""Add acquired objects to their panel.

The objects are added to the current group of the panel if the **New
group** option is off and the panel has a current group, otherwise to a
new group.

Returns:
Title of the group where the objects were added
"""
objects = acquisition.objects
panel_name = "signal" if isinstance(objects[0], SignalObj) else "image"
panel = (
self.main.signalpanel if panel_name == "signal" else self.main.imagepanel
)
with self.main.context_no_refresh():
group = panel.add_group(acquisition.group_title)
try:
group_id = None
if not self.new_group_box.isChecked():
group_id = panel.objview.get_current_group_id()
if group_id is None:
group = panel.add_group(acquisition.group_title)
try:
# pylint: disable=protected-access
panel._add_objects(objects, get_uuid(group))
except Exception:
panel.objview.remove_item(get_uuid(group), refresh=False)
panel.objmodel.remove_group(group)
raise
else:
group = panel.objmodel.get_group(group_id)
# pylint: disable=protected-access
panel._add_objects(objects, get_uuid(group))
except Exception:
panel.objview.remove_item(get_uuid(group), refresh=False)
panel.objmodel.remove_group(group)
raise
panel._add_objects(objects, group_id)
self.main.set_current_panel(panel_name)
panel.objview.select_objects(objects)
return group.title
7 changes: 5 additions & 2 deletions datalab/locale/fr/LC_MESSAGES/datalab.po
Original file line number Diff line number Diff line change
Expand Up @@ -2591,8 +2591,11 @@ msgstr "Rafraîchir la vue en continu"
msgid "Acquire"
msgstr "Acquérir"

msgid "Add an acquisition to the workspace, in a new group"
msgstr "Ajouter une acquisition à l'espace de travail, dans un nouveau groupe"
msgid "Add each acquisition to a new group, otherwise to the current group of the panel"
msgstr "Ajouter chaque acquisition à un nouveau groupe, sinon au groupe courant du panneau"

msgid "Add an acquisition to the workspace"
msgstr "Ajouter une acquisition à l'espace de travail"

msgid "Invalid settings"
msgstr "Réglages non valides"
Expand Down
13 changes: 11 additions & 2 deletions datalab/plugins/instruments.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,14 @@ def __post_init__(self) -> None:

@dataclasses.dataclass(frozen=True)
class InstrumentAcquisition:
"""Objects acquired by an instrument, added by DataLab in a new group.
"""Objects acquired by an instrument, added by DataLab to the workspace.

DataLab adds them to a new group, or to the current group of the panel when
the user turns off the **New group** option of the instrument window (see
:attr:`PluginInstrument.new_group`).

Args:
group_title: title of the group created for the acquisition
group_title: title of the group created for the acquisition, if any
objects: acquired signals, or acquired images
"""

Expand Down Expand Up @@ -110,6 +114,11 @@ class PluginInstrument(abc.ABC):
#: Interval between two frames in live mode, in milliseconds
live_interval_ms: int = 500

#: Default state of the **New group** option of the instrument window (if
#: True, each acquisition is added to a new group, otherwise to the current
#: group of the panel). The choice of the user, once made, prevails.
new_group: bool = True

def __init__(self, settings: gds.DataSet) -> None:
if not isinstance(settings, gds.DataSet):
raise TypeError("Instrument settings must be a guidata DataSet")
Expand Down
2 changes: 2 additions & 0 deletions datalab/tests/backbone/config_persistence_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def test_round_trip_across_types() -> None:
src.window_size.set((1234, 567))
src.plugins_path_list.set(["/tmp/plugins_a", "/tmp/plugins_b"])
src.plugins_enabled_list.set(["Test Plugin 1"])
src.instrument_new_group.set({"org.example/tool": False})
src.macro_console_max_lines.set(4242)
src.ai_temperature.set(0.9)

Expand All @@ -173,6 +174,7 @@ def test_round_trip_across_types() -> None:
assert dst.window_size.get() == (1234, 567)
assert dst.plugins_path_list.get() == ["/tmp/plugins_a", "/tmp/plugins_b"]
assert dst.plugins_enabled_list.get() == ["Test Plugin 1"]
assert dst.instrument_new_group.get() == {"org.example/tool": False}
assert dst.macro_console_max_lines.get() == 4242
assert abs(dst.ai_temperature.get() - 0.9) < 1e-9
assert dst.traceback_log_path.to_storage() == ".DataLab_custom.log"
Expand Down
1 change: 1 addition & 0 deletions datalab/tests/backbone/plugins/plugin_tiles_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ def create_actions(self) -> None:
assert opened == [(plugin, generator)]
instrument = plugin.get_instrument("generator")
assert plugin.get_instrument("generator") is instrument
assert instrument.new_group is True
with pytest.raises(ValueError, match="no instrument"):
plugin.get_instrument("annotate")
plugin._reset_registration_state() # pylint: disable=protected-access
Expand Down
77 changes: 77 additions & 0 deletions datalab/tests/features/plugins/plugin_tools_app_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,80 @@ def failing_preview():
reloaded = PluginRegistry.get_plugin("org.example.tools-app")
assert reloaded.launch_tool("generator") is not window
win.close_plugin_instruments()


def test_instrument_new_group_option() -> None:
"""Acquisitions go to a new group, or to the current group of the panel when
the **New group** option is off; the option is remembered per instrument."""
with (
Conf.plugins_enabled.context(True),
Conf.plugins_enabled_list.context(None),
Conf.instrument_new_group.context({}),
temporary_plugin_dir() as plugin_dir,
):
with open(
f"{plugin_dir}/datalab_test_plugin_tools.py", "w", encoding="utf-8"
) as handle:
handle.write(PLUGIN_SOURCE)
with datalab_test_app_context(console=False) as win:
plugin = PluginRegistry.get_plugin("org.example.tools-app")
instrument = plugin.get_instrument("generator")
key = "org.example.tools-app/generator"
model = win.signalpanel.objmodel

def acquire(window: InstrumentWindow) -> None:
window.acquire()
assert window.wait_until_idle()

# Default state set by the instrument, nothing remembered yet
window = plugin.launch_tool("generator")
assert window.wait_until_idle()
assert window.new_group_box.isChecked()
assert key not in Conf.instrument_new_group.get()

# Option off, panel without current group: a group is created anyway
window.new_group_box.setChecked(False)
assert Conf.instrument_new_group.get()[key] is False
assert not model.get_groups()
acquire(window)
groups = model.get_groups()
assert [group.title for group in groups] == ["Generator acquisition"]
assert len(groups[0]) == 3

# Option off: the next acquisitions go to the current group
acquire(window)
assert len(model.get_groups()) == 1
assert len(groups[0]) == 6
assert "group 'Generator acquisition'" in window.status_label.text()
target = win.signalpanel.add_group("Target", select=True)
acquire(window)
assert len(model.get_groups()) == 2
assert len(groups[0]) == 6 and len(target) == 3
assert "group 'Target'" in window.status_label.text()
acquire(window)
assert len(target) == 6

# Option on: each acquisition goes to a new group
window.new_group_box.setChecked(True)
assert Conf.instrument_new_group.get()[key] is True
acquire(window)
groups = model.get_groups()
assert len(groups) == 3 and len(groups[-1]) == 3
assert len(target) == 6

# The choice of the user is remembered, and prevails over the default
window.new_group_box.setChecked(False)
window.close()
QW.QApplication.processEvents()
window = plugin.launch_tool("generator")
assert not window.new_group_box.isChecked()
window.close()
QW.QApplication.processEvents()

# Without a remembered choice, the instrument sets the default state
Conf.instrument_new_group.set({})
instrument.new_group = False
window = plugin.launch_tool("generator")
assert not window.new_group_box.isChecked()
assert key not in Conf.instrument_new_group.get()
win.close_plugin_instruments()
6 changes: 4 additions & 2 deletions doc/features/advanced/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ The launcher is called without arguments. :meth:`datalab.plugins.PluginBase.get_
Instruments
^^^^^^^^^^^

A tool may name an ``instrument`` method instead of a ``launcher``. This method returns a :class:`datalab.plugins.instruments.PluginInstrument`, and DataLab opens a window for it: a live view on the left, the instrument settings on the right, a **Live** button that refreshes the view continuously, and an **Acquire** button. The plugin writes no user interface code:
A tool may name an ``instrument`` method instead of a ``launcher``. This method returns a :class:`datalab.plugins.instruments.PluginInstrument`, and DataLab opens a window for it: a live view on the left, the instrument settings on the right, a **Live** button that refreshes the view continuously, a **New group** option and an **Acquire** button. The plugin writes no user interface code:

.. code-block:: python

Expand Down Expand Up @@ -788,7 +788,9 @@ A tool may name an ``instrument`` method instead of a ``launcher``. This method
def signal_source(self):
return Source()

DataLab edits :attr:`~datalab.plugins.instruments.PluginInstrument.settings` in place, with the active state, groups and tabs of the guidata DataSet, then calls :meth:`~datalab.plugins.instruments.PluginInstrument.preview` after each change and, in live mode, every ``live_interval_ms`` milliseconds. An :class:`~datalab.plugins.instruments.InstrumentFrame` holds signals drawn together or a single image, a short summary shown below the view, and an optional fixed Y range (signals) or color range (image). :meth:`~datalab.plugins.instruments.PluginInstrument.acquire` returns an :class:`~datalab.plugins.instruments.InstrumentAcquisition`: DataLab adds its objects to a new group of the matching panel and selects them, so that a recipe can run on them at once. Both methods raise ``ValueError`` with a user-facing message when the settings cannot be used; DataLab shows the message in the window.
DataLab edits :attr:`~datalab.plugins.instruments.PluginInstrument.settings` in place, with the active state, groups and tabs of the guidata DataSet, then calls :meth:`~datalab.plugins.instruments.PluginInstrument.preview` after each change and, in live mode, every ``live_interval_ms`` milliseconds. An :class:`~datalab.plugins.instruments.InstrumentFrame` holds signals drawn together or a single image, a short summary shown below the view, and an optional fixed Y range (signals) or color range (image). :meth:`~datalab.plugins.instruments.PluginInstrument.acquire` returns an :class:`~datalab.plugins.instruments.InstrumentAcquisition`: DataLab adds its objects to a new group of the matching panel, titled after the acquisition, and selects them, so that a recipe can run on them at once. Both methods raise ``ValueError`` with a user-facing message when the settings cannot be used; DataLab shows the message in the window.

When the user turns off the **New group** option of the window, DataLab adds the acquired objects to the current group of the panel instead: as they are selected, the next acquisitions go to the same group, until the user selects another group. A new group is still created when the panel has no current group, for example when it is empty. The :attr:`~datalab.plugins.instruments.PluginInstrument.new_group` class attribute sets the default state of the option (``True`` by default): an instrument whose acquisitions are usually single objects, such as a device attribute read on demand, may set it to ``False``. The choice of the user, once made, is remembered for each instrument and prevails over this default.

DataLab creates the instrument once per tool and keeps it until the plugins are reloaded, so the settings remain from one opening to the next.

Expand Down
Loading
Loading