diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 28b6a3b..0a543ea 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -421,6 +421,22 @@ class FlomniSampleTransferMixin: if not low: raise FlomniError("Ftray is not at the 'IN' position. Aborting.") + def ftransfer_confirm_dialog(self, message: str, default: str = "none") -> bool: + """ + Yes/No confirmation for sample-transfer steps. + + Uses the GUI console widget (ConsoleButtonsWidget, opened by + flomnigui_show_cameras()) when it's already available, so the + operator can confirm/abort right next to the live camera view. + Falls back to the regular console yesno() prompt if the + cameras/console haven't been opened yet -- e.g. if a transfer step + is called directly, outside the usual ftransfer_get_sample / + ftransfer_put_sample sequence that opens them first. + """ + if self.console is not None: + return self.OMNYTools.gui_yesno(message, gui=self.console, default=default) + return self.OMNYTools.yesno(message, default) + def ftransfer_flomni_stage_in(self): time.sleep(1) sample_in_position = dev.flomni_samples.is_sample_slot_used(0) @@ -879,6 +895,11 @@ class FlomniSampleTransferMixin: self.check_position_is_valid(new_sample_position) + if new_sample_position == 0: + raise FlomniError( + "The new sample to place cannot be the sample in the sample stage. Aborting." + ) + # sample_placed = getattr( # dev.flomni_samples.sample_placed, f"sample{new_sample_position}" # ).get() @@ -888,11 +909,6 @@ class FlomniSampleTransferMixin: f"There is currently no sample in position [{new_sample_position}]. Aborting." ) - if new_sample_position == 0: - raise FlomniError( - "The new sample to place cannot be the sample in the sample stage. Aborting." - ) - # sample_in_sample_stage = dev.flomni_samples.sample_placed.sample0.get() sample_in_sample_stage = dev.flomni_samples.is_sample_slot_used(0) if sample_in_sample_stage: @@ -985,7 +1001,7 @@ class FlomniSampleTransferMixin: return self.transfer_step += 1 - if self.OMNYTools.yesno("All OK? Continue?", "y"): + if self.ftransfer_confirm_dialog("All OK? Continue?", "y"): print("OK. continue.") data = self.client.connector.get_last( MessageEndpoints.device_preview("cam_flomni_gripper", "preview") diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py index eb0ff80..f3d953f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py @@ -22,6 +22,14 @@ class flomniGuiToolsError(Exception): class flomniGuiTools: + # Screen assumed 2560x1440. Window is right-aligned with a small margin + # from the top; width/height tuned from the first test render (which + # came out taller than intended at 1000px). + _SCREEN_WIDTH = 2560 + _WINDOW_WIDTH = 1500 + _WINDOW_HEIGHT = 850 + _WINDOW_TOP_MARGIN = 50 + def __init__(self): self.text_box = None self.progressbar = None @@ -31,6 +39,7 @@ class flomniGuiTools: self.idle_text_box = None self.camera_gripper_image = None self.camera_overview_image = None + self.console = None def set_client(self, client): self.client = client @@ -41,7 +50,10 @@ class flomniGuiTools: self.flomni_window = self.gui.windows["flomni"] self.gui.flomni.raise_window() else: - self.flomni_window = self.gui.new("flomni") + # geometry: (pos_x, pos_y, w, h) + pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH + geometry = (pos_x, self._WINDOW_TOP_MARGIN, self._WINDOW_WIDTH, self._WINDOW_HEIGHT) + self.flomni_window = self.gui.new("flomni", geometry=geometry) time.sleep(1) def flomnigui_stop_gui(self): @@ -90,8 +102,10 @@ class flomniGuiTools: def flomnigui_show_cameras(self): self.flomnigui_show_gui() - if self._flomnigui_is_missing("camera_gripper_image") or self._flomnigui_is_missing( - "camera_overview_image" + if ( + self._flomnigui_is_missing("camera_gripper_image") + or self._flomnigui_is_missing("camera_overview_image") + or self._flomnigui_is_missing("console") ): self.flomnigui_remove_all_docks() self.camera_gripper_image = self.gui.flomni.new("Image") @@ -117,6 +131,16 @@ class flomniGuiTools: else: print("Cannot open camera_overview. Device does not exist.") + # Confirm/abort console, docked below the cameras. + self.console = self.gui.flomni.new( + "ConsoleButtonsWidget", object_name="console", where="bottom" + ) + # set_layout_ratios uses relative weights, not pixels -- there is + # no width/height kwarg on dock_area.new(). [5, 1] gives the + # cameras most of the vertical space and keeps the console a + # slim strip at the bottom; adjust to taste once you see it. + self.gui.flomni.set_layout_ratios(vertical=[5, 1]) + def flomnigui_remove_all_docks(self): # dev.cam_flomni_overview.stop_live_mode() # dev.cam_flomni_gripper.stop_live_mode() @@ -130,6 +154,7 @@ class flomniGuiTools: self.idle_text_box = None self.camera_gripper_image = None self.camera_overview_image = None + self.console = None def flomnigui_idle(self): self.flomnigui_show_gui() @@ -316,4 +341,4 @@ if __name__ == "__main__": flomni_gui = flomniGuiTools() flomni_gui.set_client(client) flomni_gui.flomnigui_show_gui() - flomni_gui.flomnigui_show_progress() + flomni_gui.flomnigui_show_progress() \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py index ca05a82..537ab80 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py @@ -93,6 +93,71 @@ class OMNYTools: else: print("Please expicitely confirm y or n.") + def gui_yesno( + self, message: str, gui, default="none", autoconfirm=0, poll_interval: float = 0.1 + ) -> bool: + """ + GUI-based alternative to yesno(), using a ConsoleButtonsWidget + (csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py) + instead of a blocking console input() prompt. + + Not yet wired up as a replacement for yesno() anywhere -- this is + for standalone testing. Once confirmed working, call sites can be + switched over deliberately, one at a time. + + Opening the GUI (must already exist and be passed in as `gui`; + this method does not create it): + + gui.new("test", timeout=20) + console = gui.test.new("ConsoleButtonsWidget", object_name="console", timeout=20) + + Then call, e.g. from an IPython session: + + omny_tools.gui_yesno("Continue with sample transfer?", gui=console) + + Args: + message (str): Question to display on the widget. + gui: The already-open ConsoleButtonsWidget RPC object (e.g. the + `console` object created above, or later a fixed instance + such as `gui.flomni.console` once wired into flomni's own + gui tools). + default (str): "y" or "n" -- only affects the autoconfirm path, + same as yesno(). There is no "just press enter" equivalent + for a button click, so this has no effect otherwise. + autoconfirm (int): if set together with default="y"/"n", skips + the GUI entirely and returns immediately, same as yesno(). + poll_interval (float): seconds between response checks, same + style as the 0.1 s submit-poll in XrayEyeAlign.align(). + + Returns: + bool: True for "yes", False for "no". + + Note on abort: the widget's ABORT button does not write a response + to poll for -- it sends a real SIGINT directly to this process (see + ConsoleButtonsWidget._on_abort), so pressing it raises + KeyboardInterrupt here exactly as a console Ctrl+C would, and + propagates normally out of this method without any special-casing. + """ + if autoconfirm and default == "y": + self.printgreen(message + " Automatically confirming default: yes") + return True + elif autoconfirm and default == "n": + self.printgreen(message + " Automatically confirming default: no") + return False + + suffix = {"y": " [Y]/n?", "n": " y/[N]?"}.get(default, " y/n?") + gui.clear_response() + gui.message = message + suffix + while True: + response = gui.response() + if response == "yes": + gui.clear_response() + return True + if response == "no": + gui.clear_response() + return False + time.sleep(poll_interval) + def tweak_cursor( self, dev1, step1: float, dev2="none", step2: float = "0", special_command="none" ): diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index c3c4192..381fef2 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -12,6 +12,7 @@ logger = bec_logger.logger _Widgets = { + "ConsoleButtonsWidget": "ConsoleButtonsWidget", "SampleStorageWidget": "SampleStorageWidget", "SAXSWidget": "SAXSWidget", "SlitControlWidget": "SlitControlWidget", @@ -20,6 +21,47 @@ _Widgets = { } +class ConsoleButtonsWidget(RPCBase): + """Small Yes / No / Abort control widget, intended as a GUI replacement for""" + + _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons" + + @property + @rpc_call + def message(self): + """ + None + """ + + @message.setter + @rpc_call + def message(self): + """ + None + """ + + @rpc_call + def response(self) -> "str": + """ + Current button response: "yes", "no", or "" if no button has been + pressed (or since the last clear_response()). + + Note: intentionally a plain method, not @SafeProperty. SafeProperty + only becomes a real Qt property descriptor once a setter is chained + in the class body (see `message` above); a getter-only SafeProperty + is left as an unconverted internal wrapper object, which the RPC + generator then exposes as a callable stub anyway. Since `response` + has no RPC-facing setter, a plain method avoids that trap entirely - + same pattern XRayEye uses for its own read-only `active_roi`. + """ + + @rpc_call + def clear_response(self): + """ + None + """ + + class SampleStorageWidget(RPCBase): """View and correct the FlOMNI sample-storage records.""" @@ -28,7 +70,14 @@ class SampleStorageWidget(RPCBase): @rpc_call def refresh(self) -> "None": """ - Re-read every slot from the device and update the cells. + Re-read all slots (one bulk device round-trip) and update only the + cells whose state actually changed. + + Repainting every cell on every 2 s poll — even when nothing changed, + which is almost always the case for a sample magazine — was needless + work on the GUI thread. We diff against the last-seen state and only + call set_state() on cells that differ, so a steady-state poll does no + UI work at all. """ diff --git a/csaxs_bec/bec_widgets/widgets/console_buttons/__init__.py b/csaxs_bec/bec_widgets/widgets/console_buttons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py new file mode 100644 index 0000000..f36680d --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import os +import signal + +from bec_lib import bec_logger +from bec_widgets import BECWidget, SafeProperty, SafeSlot +from bec_widgets.utils.rpc_decorator import rpc_timeout +from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +logger = bec_logger.logger + + +class ConsoleButtonsWidget(BECWidget, QWidget): + """ + Small Yes / No / Abort control widget, intended as a GUI replacement for + console prompts (e.g. ``OMNYTools.yesno()``) and as a general-purpose + emergency-stop control. + + - Yes / No: set ``response`` to "yes" / "no". A blocking CLI script can + poll ``gui..response`` and reset it via ``clear_response()``. + - Abort: sends a real SIGINT to the BEC IPython client process (the + parent of the GUI server process), equivalent to pressing Ctrl+C in + the console. This works even if the client is blocked inside a motor + move or other long call, since it is a real OS signal rather than a + polled flag. + + Not yet wired into any CLI method -- this widget is for standalone + testing first (open it manually with ``gui.new(...)`` and click the + buttons / read back ``response``). + """ + + USER_ACCESS = ["message", "message.setter", "response", "clear_response"] + PLUGIN = True + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self._response = "" + # Captured once at construction time: the GUI server process is a + # direct child of the BEC IPython client (subprocess.Popen in + # bec_widgets.cli.client_utils), so getppid() here is the client's + # PID. Not re-read later, so a subsequent reparenting (e.g. if the + # client died) can't silently redirect the signal. + self._client_pid = os.getppid() + self._init_ui() + + def _init_ui(self): + layout = QVBoxLayout(self) + + button_row = QHBoxLayout() + self.yes_button = QPushButton("Yes", parent=self) + self.no_button = QPushButton("No", parent=self) + self.abort_button = QPushButton("ABORT", parent=self) + self.abort_button.setStyleSheet( + "background-color: #c0392b; color: white; font-weight: bold;" + ) + button_row.addWidget(self.yes_button) + button_row.addWidget(self.no_button) + button_row.addWidget(self.abort_button) + layout.addLayout(button_row) + + self.message_label = QLabel("", parent=self) + self.message_label.setWordWrap(True) + layout.addWidget(self.message_label) + + self.yes_button.clicked.connect(self._on_yes) + self.no_button.clicked.connect(self._on_no) + self.abort_button.clicked.connect(self._on_abort) + + @SafeSlot() + def _on_yes(self): + self._response = "yes" + + @SafeSlot() + def _on_no(self): + self._response = "no" + + @SafeSlot() + def _on_abort(self): + logger.warning(f"ConsoleButtonsWidget: sending SIGINT to client pid {self._client_pid}") + os.kill(self._client_pid, signal.SIGINT) + + @SafeProperty(str) + def message(self): + return self.message_label.text() + + @message.setter + @rpc_timeout(20) + def message(self, text: str): + self.message_label.setText(text) + + def response(self) -> str: + """ + Current button response: "yes", "no", or "" if no button has been + pressed (or since the last clear_response()). + + Note: intentionally a plain method, not @SafeProperty. SafeProperty + only becomes a real Qt property descriptor once a setter is chained + in the class body (see `message` above); a getter-only SafeProperty + is left as an unconverted internal wrapper object, which the RPC + generator then exposes as a callable stub anyway. Since `response` + has no RPC-facing setter, a plain method avoids that trap entirely - + same pattern XRayEye uses for its own read-only `active_roi`. + """ + return self._response + + @SafeSlot() + def clear_response(self): + self._response = "" \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons_widget.pyproject b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons_widget.pyproject new file mode 100644 index 0000000..072ea3c --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons_widget.pyproject @@ -0,0 +1 @@ +{'files': ['console_buttons.py']} \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons_widget_plugin.py b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons_widget_plugin.py new file mode 100644 index 0000000..ca1e7c9 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons_widget_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons import ConsoleButtonsWidget + +DOM_XML = """ + + + + +""" + + +class ConsoleButtonsWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = ConsoleButtonsWidget(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(ConsoleButtonsWidget.ICON_NAME) + + def includeFile(self): + return "console_buttons_widget" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "ConsoleButtonsWidget" + + def toolTip(self): + return "" + + def whatsThis(self): + return self.toolTip() diff --git a/csaxs_bec/bec_widgets/widgets/console_buttons/register_console_buttons_widget.py b/csaxs_bec/bec_widgets/widgets/console_buttons/register_console_buttons_widget.py new file mode 100644 index 0000000..a6115fd --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/console_buttons/register_console_buttons_widget.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons_widget_plugin import ConsoleButtonsWidgetPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(ConsoleButtonsWidgetPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/csaxs_bec/bec_widgets/widgets/designer_plugins.py b/csaxs_bec/bec_widgets/widgets/designer_plugins.py index ca0ff0c..63226c3 100644 --- a/csaxs_bec/bec_widgets/widgets/designer_plugins.py +++ b/csaxs_bec/bec_widgets/widgets/designer_plugins.py @@ -5,6 +5,10 @@ from __future__ import annotations # pylint: skip-file designer_plugins = { + "ConsoleButtonsWidget": ( + "csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons", + "ConsoleButtonsWidget", + ), "SampleStorageWidget": ( "csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage", "SampleStorageWidget", @@ -22,6 +26,7 @@ designer_plugins = { } widget_icons = { + "ConsoleButtonsWidget": "widgets", "SampleStorageWidget": "widgets", "SAXSWidget": "table_chart", "SlitControlWidget": "widgets",