From 02cb393bb086165dc64917b633d5570d02e1a2a9 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 7 Apr 2026 17:52:40 +0200 Subject: [PATCH] feat: add qtermwidget plugin and replace web term --- .gitignore | 4 +- .../views/developer_view/developer_widget.py | 24 +- bec_widgets/cli/client.py | 48 +- .../widgets/containers/dock_area/dock_area.py | 4 +- .../{web_console => bec_console}/__init__.py | 0 .../editors/bec_console/bec_console.py | 431 +++++++++++ .../editors/bec_console/bec_console.pyproject | 1 + .../bec_console_plugin.py} | 20 +- .../editors/bec_console/bec_shell.pyproject | 1 + .../bec_shell_plugin.py | 4 +- .../register_bec_console.py} | 4 +- .../register_bec_shell.py | 2 +- .../editors/web_console/bec_shell.pyproject | 1 - .../editors/web_console/web_console.py | 705 ------------------ .../editors/web_console/web_console.pyproject | 1 - .../widgets/utility/bec_term/__init__.py | 11 + .../widgets/utility/bec_term/protocol.py | 8 + .../utility/bec_term/qtermwidget_wrapper.py | 241 ++++++ bec_widgets/widgets/utility/bec_term/util.py | 6 + pyproject.toml | 147 ++-- tests/end-2-end/test_rpc_widgets_e2e.py | 4 +- tests/unit_tests/test_dock_area.py | 4 +- tests/unit_tests/test_web_console.py | 130 ++-- 23 files changed, 890 insertions(+), 911 deletions(-) rename bec_widgets/widgets/editors/{web_console => bec_console}/__init__.py (100%) create mode 100644 bec_widgets/widgets/editors/bec_console/bec_console.py create mode 100644 bec_widgets/widgets/editors/bec_console/bec_console.pyproject rename bec_widgets/widgets/editors/{web_console/web_console_plugin.py => bec_console/bec_console_plugin.py} (66%) create mode 100644 bec_widgets/widgets/editors/bec_console/bec_shell.pyproject rename bec_widgets/widgets/editors/{web_console => bec_console}/bec_shell_plugin.py (92%) rename bec_widgets/widgets/editors/{web_console/register_web_console.py => bec_console/register_bec_console.py} (67%) rename bec_widgets/widgets/editors/{web_console => bec_console}/register_bec_shell.py (86%) delete mode 100644 bec_widgets/widgets/editors/web_console/bec_shell.pyproject delete mode 100644 bec_widgets/widgets/editors/web_console/web_console.py delete mode 100644 bec_widgets/widgets/editors/web_console/web_console.pyproject create mode 100644 bec_widgets/widgets/utility/bec_term/__init__.py create mode 100644 bec_widgets/widgets/utility/bec_term/protocol.py create mode 100644 bec_widgets/widgets/utility/bec_term/qtermwidget_wrapper.py create mode 100644 bec_widgets/widgets/utility/bec_term/util.py diff --git a/.gitignore b/.gitignore index 607f8eee..1f5f9435 100644 --- a/.gitignore +++ b/.gitignore @@ -177,4 +177,6 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +#.idea/ +# +tombi.toml diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index a30ca295..d3eecd48 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -16,9 +16,9 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.qt_ads import CDockWidget +from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget -from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -96,7 +96,7 @@ class DeveloperWidget(DockAreaWidget): self.console = BECShell(self, rpc_exposed=False) self.console.setObjectName("BEC Shell") - self.terminal = WebConsole(self, rpc_exposed=False) + self.terminal = BecConsole(self, rpc_exposed=False) self.terminal.setObjectName("Terminal") self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False) self.monaco.setObjectName("MonacoEditor") @@ -410,23 +410,3 @@ class DeveloperWidget(DockAreaWidget): """Clean up resources used by the developer widget.""" self.delete_all() return super().cleanup() - - -if __name__ == "__main__": - import sys - - from bec_qthemes import apply_theme - from qtpy.QtWidgets import QApplication - - from bec_widgets.applications.main_app import BECMainApp - - app = QApplication(sys.argv) - apply_theme("dark") - - _app = BECMainApp() - _app.show() - # developer_view.show() - # developer_view.setWindowTitle("Developer View") - # developer_view.resize(1920, 1080) - # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime - sys.exit(app.exec_()) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index fae4fa3a..3cb59560 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -32,6 +32,7 @@ _Widgets = { "BECQueue": "BECQueue", "BECShell": "BECShell", "BECStatusBox": "BECStatusBox", + "BecConsole": "BecConsole", "DapComboBox": "DapComboBox", "DeviceBrowser": "DeviceBrowser", "Heatmap": "Heatmap", @@ -56,7 +57,6 @@ _Widgets = { "SignalLabel": "SignalLabel", "TextBox": "TextBox", "Waveform": "Waveform", - "WebConsole": "WebConsole", "WebsiteWidget": "WebsiteWidget", } @@ -506,7 +506,7 @@ class BECQueue(RPCBase): class BECShell(RPCBase): - """A WebConsole pre-configured to run the BEC shell.""" + """A BecConsole pre-configured to run the BEC shell.""" @rpc_call def remove(self): @@ -691,6 +691,28 @@ class BaseROI(RPCBase): """ +class BecConsole(RPCBase): + """A console widget with access to a shared registry of terminals, such that instances can be moved around.""" + + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + class CircularROI(RPCBase): """Circular Region of Interest with center/diameter tracking and auto-labeling.""" @@ -6417,28 +6439,6 @@ class WaveformViewPopup(RPCBase): """ -class WebConsole(RPCBase): - """A simple widget to display a website""" - - @rpc_call - def remove(self): - """ - Cleanup the BECConnector - """ - - @rpc_call - def attach(self): - """ - None - """ - - @rpc_call - def detach(self): - """ - Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. - """ - - class WebsiteWidget(RPCBase): """A simple widget to display a website""" diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 3a86e8c1..18b2ee02 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -69,7 +69,7 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow from bec_widgets.widgets.containers.qt_ads import CDockWidget from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D from bec_widgets.widgets.control.scan_control import ScanControl -from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole +from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap @@ -372,7 +372,7 @@ class BECDockArea(DockAreaWidget): "Add Circular ProgressBar", "RingProgressBar", ), - "terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"), + "terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"), "bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"), "log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), "sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), diff --git a/bec_widgets/widgets/editors/web_console/__init__.py b/bec_widgets/widgets/editors/bec_console/__init__.py similarity index 100% rename from bec_widgets/widgets/editors/web_console/__init__.py rename to bec_widgets/widgets/editors/bec_console/__init__.py diff --git a/bec_widgets/widgets/editors/bec_console/bec_console.py b/bec_widgets/widgets/editors/bec_console/bec_console.py new file mode 100644 index 00000000..2161c9c4 --- /dev/null +++ b/bec_widgets/widgets/editors/bec_console/bec_console.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import enum +from uuid import uuid4 +from weakref import WeakValueDictionary + +from bec_lib.logger import bec_logger +from pydantic import BaseModel +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QMouseEvent, QResizeEvent +from qtpy.QtWidgets import ( + QApplication, + QHBoxLayout, + QLabel, + QStackedLayout, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.widgets.utility.bec_term.protocol import BecTerminal +from bec_widgets.widgets.utility.bec_term.util import get_current_bec_term_class + +logger = bec_logger.logger + +_BecTermClass = get_current_bec_term_class() + +# Note on definitions: +# Terminal: an instance of a terminal widget with a system shell +# Console: one of possibly several widgets which may share ownership of one single terminal +# Shell: a Console set to start the BEC IPython client in its terminal + + +class ConsoleMode(str, enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + HIDDEN = "hidden" + + +class _TerminalOwnerInfo(BaseModel): + """Should be managed only by the BecConsoleRegistry. Consoles should ask the registry for + necessary ownership info.""" + + owner_console_id: str | None = None + registered_console_ids: set[str] = set() + instance: BecTerminal + terminal_id: str + initialized: bool = False + + model_config = {"arbitrary_types_allowed": True} + + +class BecConsoleRegistry: + """ + A registry for the BecConsole class to manage its instances. + """ + + def __init__(self): + """ + Initialize the registry. + """ + self._consoles: WeakValueDictionary[str, BecConsole] = WeakValueDictionary() + self._terminal_registry: dict[str, _TerminalOwnerInfo] = {} + + def register(self, console: BecConsole): + """ + Register an instance of BecConsole. If there is already a terminal with the associated + terminal_id, this does not automatically grant ownership. + + Args: + console (BecConsole): The instance to register. + """ + self._consoles[console.console_id] = console + console_id, terminal_id = console.console_id, console.terminal_id + if (term_info := self._terminal_registry.get(terminal_id)) is None: + term = _BecTermClass() + self._terminal_registry[terminal_id] = _TerminalOwnerInfo( + registered_console_ids={console_id}, + owner_console_id=console_id, + instance=term, + terminal_id=terminal_id, + ) + return + + logger.info(f"Registered new console {console_id} for terminal {terminal_id}") + term_info.registered_console_ids.add(console_id) + + def unregister(self, console: BecConsole): + """ + Unregister an instance of BecConsole. + + Args: + instance (BecConsole): The instance to unregister. + """ + console_id, terminal_id = console.console_id, console.terminal_id + if console_id in self._consoles: + del self._consoles[console_id] + if (term_info := self._terminal_registry.get(console_id)) is None: + return + if console_id in term_info.registered_console_ids: + term_info.registered_console_ids.remove(console_id) + if term_info.owner_console_id == console_id: + term_info.owner_console_id = None + if not term_info.registered_console_ids: + term_info.instance.deleteLater() + del self._terminal_registry[terminal_id] + + logger.info(f"Unregistered console {console_id} for terminal {terminal_id}") + + def is_owner(self, console: BecConsole): + """Returns true if the given console is the owner of its terminal""" + if console not in self._consoles.values(): + return False + if (info := self._terminal_registry.get(console.terminal_id)) is None: + logger.warning(f"Console {console.console_id} references an unknown terminal!") + return False + return info.owner_console_id == console.console_id + + def take_ownership(self, console: BecConsole) -> BecTerminal | None: + """ + Transfer ownership of a terminal to the given console. + + Args: + console: the console which wishes to take ownership of its associated terminal. + Returns: + BecTerminal | None: The instance if ownership transfer was successful, None otherwise. + """ + console_id, terminal_id = console.console_id, console.terminal_id + + if terminal_id not in self._terminal_registry: + logger.warning(f"Terminal {terminal_id} not found in registry") + return None + + instance_info = self._terminal_registry[terminal_id] + if (old_owner_console_ide := instance_info.owner_console_id) is not None: + if (old_owner := self._consoles.get(old_owner_console_ide)) is not None: + old_owner.yield_ownership() # call this on the old owner to make sure it is updated + instance_info.owner_console_id = console_id + logger.info(f"Transferred ownership of terminal {terminal_id} to {console_id}") + return instance_info.instance + + def try_get_term(self, console: BecConsole) -> BecTerminal | None: + """ + Return the terminal instance if the requesting console is the owner + + Args: + console: the requesting console. + Returns: + BecTerminal | None: The instance if the console is the owner, None otherwise. + """ + console_id, terminal_id = console.console_id, console.terminal_id + logger.debug(f"checking term for {console_id}") + if terminal_id not in self._terminal_registry: + logger.warning(f"Terminal {terminal_id} not found in registry") + return None + + instance_info = self._terminal_registry[terminal_id] + if instance_info.owner_console_id == console_id: + return instance_info.instance + + def yield_ownership(self, console: BecConsole): + """ + Yield ownership of a instance without destroying it. The instance remains in the + registry with no owner, available for another widget to claim. + + Args: + gui_id (str): The GUI ID of the widget yielding ownership. + + """ + console_id, terminal_id = console.console_id, console.terminal_id + logger.debug(f"Console {console_id} attempted to yield ownership") + if console_id not in self._consoles or terminal_id not in self._terminal_registry: + return + + term_info = self._terminal_registry[terminal_id] + if term_info.owner_console_id != console_id: + logger.debug(f"But it was not the owner, which was {term_info.owner_console_id}!") + return + term_info.owner_console_id = None + term_info.instance.setParent(None) + + def owner_is_visible(self, term_id: str) -> bool: + """ + Check if the owner of a instance is currently visible. + + Args: + unique_id (str): The unique identifier for the instance. + Returns: + bool: True if the owner is visible, False otherwise. + """ + instance_info = self._terminal_registry.get(term_id) + if instance_info is None or instance_info.owner_console_id is None: + return False + + if (owner := self._consoles.get(instance_info.owner_console_id)) is None: + return False + return owner.isVisible() + + +_bec_console_registry = BecConsoleRegistry() + + +class _Overlay(QWidget): + def __init__(self, console: BecConsole): + super().__init__(parent=console) + self._console = console + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self._console.take_terminal_ownership() + event.accept() + return + return super().mousePressEvent(event) + + +class BecConsole(BECWidget, QWidget): + """A console widget with access to a shared registry of terminals, such that instances can be moved around.""" + + _js_callback = Signal(bool) + initialized = Signal() + + PLUGIN = True + ICON_NAME = "terminal" + + def __init__( + self, + parent=None, + config=None, + client=None, + gui_id=None, + startup_cmd: str | None = None, + is_bec_shell: bool = False, + terminal_id: str | None = None, + **kwargs, + ): + super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) + self._mode = ConsoleMode.INACTIVE + self._is_bec_shell = is_bec_shell + self._startup_cmd = startup_cmd + self._is_initialized = False + self.terminal_id = terminal_id or str(uuid4()) + self.console_id = self.gui_id + self.term: BecTerminal | None = None # Will be set in _set_up_instance + + self._set_up_instance() + + def _set_up_instance(self): + """ + Set up the web instance and UI elements. + """ + self._stacked_layout = QStackedLayout() + # self._stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) + self._term_holder = QWidget() + self._term_layout = QVBoxLayout() + self._term_layout.setContentsMargins(0, 0, 0, 0) + self._term_holder.setLayout(self._term_layout) + + self.setLayout(self._stacked_layout) + + # prepare overlay + self._overlay = _Overlay(self) + layout = QVBoxLayout(self._overlay) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + label = QLabel("Click to activate terminal", self._overlay) + layout.addWidget(label) + + self._stacked_layout.addWidget(self._term_holder) + self._stacked_layout.addWidget(self._overlay) + + # will create a new terminal instance if there isn't already one for this ID + _bec_console_registry.register(self) + self._infer_mode() + if self.startup_cmd: + self.write(self.startup_cmd, True) # will have no effect if not the owner + + def _infer_mode(self): + self.term = _bec_console_registry.try_get_term(self) + if self.term: + self._set_mode(ConsoleMode.ACTIVE) + elif self.isHidden: + self._set_mode(ConsoleMode.HIDDEN) + else: + self._set_mode(ConsoleMode.INACTIVE) + + def _set_mode(self, mode: ConsoleMode): + """ + Set the mode of the web console. + + Args: + mode (ConsoleMode): The mode to set. + """ + + match mode: + case ConsoleMode.ACTIVE: + if self.term: + if self.term not in (self._term_layout.children()): + self._term_layout.addWidget(self.term) # type: ignore # BecTerminal is QWidget + self._stacked_layout.setCurrentIndex(0) + self._mode = mode + else: + self._stacked_layout.setCurrentIndex(1) + self._mode = ConsoleMode.INACTIVE + case ConsoleMode.INACTIVE: + self._stacked_layout.setCurrentIndex(1) + self._mode = mode + case ConsoleMode.HIDDEN: + self._stacked_layout.setCurrentIndex(1) + self._mode = mode + + @property + def startup_cmd(self): + """ + Get the startup command for the web console. + """ + return self._startup_cmd + + @startup_cmd.setter + def startup_cmd(self, cmd: str | None): + """ + Set the startup command for the console. + logger.info(f"{self._console_id} inferred mode active through ownerp) + """ + self._startup_cmd = cmd + + def write(self, data: str, send_return: bool = True): + """ + Send data to the console + + Args: + data (str): The data to send. + send_return (bool): Whether to send a return after the data. + """ + if self.term: + self.term.write(data, send_return) + + def take_terminal_ownership(self): + """ + Take ownership of a web instance from the registry. This will transfer the instance + from its current owner (if any) to this widget. + """ + # Get the instance from registry + self.term = _bec_console_registry.take_ownership(self) + self._infer_mode() + if self._mode == ConsoleMode.ACTIVE: + logger.debug(f"Widget {self.gui_id} took ownership of instance {self.terminal_id}") + + def yield_ownership(self): + """ + Yield ownership of the instance. The instance remains in the registry with no owner, + available for another widget to claim. This is automatically called when the + widget becomes hidden. + """ + _bec_console_registry.yield_ownership(self) + self._infer_mode() + if self._mode != ConsoleMode.ACTIVE: + logger.debug(f"Widget {self.gui_id} yielded ownership of instance {self.terminal_id}") + + def hideEvent(self, event): + """Called when the widget is hidden. Automatically yields ownership.""" + self.yield_ownership() + super().hideEvent(event) + + def showEvent(self, event): + """Called when the widget is shown. Updates UI state based on ownership.""" + super().showEvent(event) + if not _bec_console_registry.is_owner(self): + if not _bec_console_registry.owner_is_visible(self.terminal_id): + self.take_terminal_ownership() + + def cleanup(self): + """Unregister this console on destruction.""" + _bec_console_registry.unregister(self) + super().cleanup() + + +class BECShell(BecConsole): + """ + A BecConsole pre-configured to run the BEC shell. + We cannot simply expose the web console properties to Qt as we need to have a deterministic + startup behavior for sharing the same shell instance across multiple widgets. + """ + + ICON_NAME = "hub" + + def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + super().__init__( + parent=parent, + config=config, + client=client, + gui_id=gui_id, + terminal_id="bec_shell", + **kwargs, + ) + + @property + def startup_cmd(self): + """ + Get the startup command for the BEC shell. + """ + if self.bec_dispatcher.cli_server is None: + return "bec --nogui" + return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}" + + @startup_cmd.setter + def startup_cmd(self, cmd: str | None): ... + + +if __name__ == "__main__": # pragma: no cover + import sys + + app = QApplication(sys.argv) + widget = QTabWidget() + + # Create two consoles with different unique_ids + bec_console_1a = BecConsole(startup_cmd="htop", gui_id="console_1_a", terminal_id="terminal_1") + bec_console_1b = BecConsole(startup_cmd="htop", gui_id="console_1_b", terminal_id="terminal_1") + bec_console_1 = QWidget() + bec_console_1_layout = QHBoxLayout(bec_console_1) + bec_console_1_layout.addWidget(bec_console_1a) + bec_console_1_layout.addWidget(bec_console_1b) + bec_console2 = BECShell() + bec_console3 = BecConsole(gui_id="console_3", terminal_id="terminal_1") + widget.addTab(bec_console_1, "Console 1") + widget.addTab(bec_console2, "Console 2 - BEC Shell") + widget.addTab(bec_console3, "Console 3 -- mirror of Console 1") + widget.show() + + widget.resize(800, 600) + + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/editors/bec_console/bec_console.pyproject b/bec_widgets/widgets/editors/bec_console/bec_console.pyproject new file mode 100644 index 00000000..1692eda2 --- /dev/null +++ b/bec_widgets/widgets/editors/bec_console/bec_console.pyproject @@ -0,0 +1 @@ +{'files': ['bec_console.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/editors/web_console/web_console_plugin.py b/bec_widgets/widgets/editors/bec_console/bec_console_plugin.py similarity index 66% rename from bec_widgets/widgets/editors/web_console/web_console_plugin.py rename to bec_widgets/widgets/editors/bec_console/bec_console_plugin.py index 8fa8b6f2..061a3733 100644 --- a/bec_widgets/widgets/editors/web_console/web_console_plugin.py +++ b/bec_widgets/widgets/editors/bec_console/bec_console_plugin.py @@ -5,38 +5,38 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_designer import designer_material_icon -from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole DOM_XML = """ - + """ -class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover +class BecConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover def __init__(self): super().__init__() self._form_editor = None def createWidget(self, parent): if parent is None: - return QWidget() - t = WebConsole(parent) + return QWidget() + t = BecConsole(parent) return t def domXml(self): return DOM_XML def group(self): - return "BEC Developer" + return "" def icon(self): - return designer_material_icon(WebConsole.ICON_NAME) + return designer_material_icon(BecConsole.ICON_NAME) def includeFile(self): - return "web_console" + return "bec_console" def initialize(self, form_editor): self._form_editor = form_editor @@ -48,10 +48,10 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover return self._form_editor is not None def name(self): - return "WebConsole" + return "BecConsole" def toolTip(self): - return "" + return "A console widget with access to a shared registry of terminals, such that instances can be moved around." def whatsThis(self): return self.toolTip() diff --git a/bec_widgets/widgets/editors/bec_console/bec_shell.pyproject b/bec_widgets/widgets/editors/bec_console/bec_shell.pyproject new file mode 100644 index 00000000..1692eda2 --- /dev/null +++ b/bec_widgets/widgets/editors/bec_console/bec_shell.pyproject @@ -0,0 +1 @@ +{'files': ['bec_console.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/editors/web_console/bec_shell_plugin.py b/bec_widgets/widgets/editors/bec_console/bec_shell_plugin.py similarity index 92% rename from bec_widgets/widgets/editors/web_console/bec_shell_plugin.py rename to bec_widgets/widgets/editors/bec_console/bec_shell_plugin.py index 92112c39..e8124fc7 100644 --- a/bec_widgets/widgets/editors/web_console/bec_shell_plugin.py +++ b/bec_widgets/widgets/editors/bec_console/bec_shell_plugin.py @@ -5,7 +5,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_designer import designer_material_icon -from bec_widgets.widgets.editors.web_console.web_console import BECShell +from bec_widgets.widgets.editors.bec_console.bec_console import BECShell DOM_XML = """ @@ -22,7 +22,7 @@ class BECShellPlugin(QDesignerCustomWidgetInterface): # pragma: no cover def createWidget(self, parent): if parent is None: - return QWidget() + return QWidget() t = BECShell(parent) return t diff --git a/bec_widgets/widgets/editors/web_console/register_web_console.py b/bec_widgets/widgets/editors/bec_console/register_bec_console.py similarity index 67% rename from bec_widgets/widgets/editors/web_console/register_web_console.py rename to bec_widgets/widgets/editors/bec_console/register_bec_console.py index e814e0ca..ae028087 100644 --- a/bec_widgets/widgets/editors/web_console/register_web_console.py +++ b/bec_widgets/widgets/editors/bec_console/register_bec_console.py @@ -6,9 +6,9 @@ def main(): # pragma: no cover return from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - from bec_widgets.widgets.editors.web_console.web_console_plugin import WebConsolePlugin + from bec_widgets.widgets.editors.bec_console.bec_console_plugin import BecConsolePlugin - QPyDesignerCustomWidgetCollection.addCustomWidget(WebConsolePlugin()) + QPyDesignerCustomWidgetCollection.addCustomWidget(BecConsolePlugin()) if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/widgets/editors/web_console/register_bec_shell.py b/bec_widgets/widgets/editors/bec_console/register_bec_shell.py similarity index 86% rename from bec_widgets/widgets/editors/web_console/register_bec_shell.py rename to bec_widgets/widgets/editors/bec_console/register_bec_shell.py index 3e556298..ef24adbe 100644 --- a/bec_widgets/widgets/editors/web_console/register_bec_shell.py +++ b/bec_widgets/widgets/editors/bec_console/register_bec_shell.py @@ -6,7 +6,7 @@ def main(): # pragma: no cover return from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - from bec_widgets.widgets.editors.web_console.bec_shell_plugin import BECShellPlugin + from bec_widgets.widgets.editors.bec_console.bec_shell_plugin import BECShellPlugin QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin()) diff --git a/bec_widgets/widgets/editors/web_console/bec_shell.pyproject b/bec_widgets/widgets/editors/web_console/bec_shell.pyproject deleted file mode 100644 index 786a751f..00000000 --- a/bec_widgets/widgets/editors/web_console/bec_shell.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['web_console.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/editors/web_console/web_console.py b/bec_widgets/widgets/editors/web_console/web_console.py deleted file mode 100644 index c7ca75da..00000000 --- a/bec_widgets/widgets/editors/web_console/web_console.py +++ /dev/null @@ -1,705 +0,0 @@ -from __future__ import annotations - -import enum -import json -import secrets -import subprocess -import time - -from bec_lib.logger import bec_logger -from louie.saferef import safe_ref -from pydantic import BaseModel -from qtpy.QtCore import Qt, QTimer, QUrl, Signal, qInstallMessageHandler -from qtpy.QtGui import QMouseEvent, QResizeEvent -from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView -from qtpy.QtWidgets import QApplication, QLabel, QTabWidget, QVBoxLayout, QWidget - -from bec_widgets.utils.bec_widget import BECWidget - -logger = bec_logger.logger - - -class ConsoleMode(str, enum.Enum): - ACTIVE = "active" - INACTIVE = "inactive" - HIDDEN = "hidden" - - -class PageOwnerInfo(BaseModel): - owner_gui_id: str | None = None - widget_ids: list[str] = [] - page: QWebEnginePage | None = None - initialized: bool = False - - model_config = {"arbitrary_types_allowed": True} - - -class WebConsoleRegistry: - """ - A registry for the WebConsole class to manage its instances. - """ - - def __init__(self): - """ - Initialize the registry. - """ - self._instances = {} - self._server_process = None - self._server_port = None - self._token = secrets.token_hex(16) - self._page_registry: dict[str, PageOwnerInfo] = {} - - def register(self, instance: WebConsole): - """ - Register an instance of WebConsole. - - Args: - instance (WebConsole): The instance to register. - """ - self._instances[instance.gui_id] = safe_ref(instance) - self.cleanup() - - if instance._unique_id: - self._register_page(instance) - - if self._server_process is None: - # Start the ttyd server if not already running - self.start_ttyd() - - def start_ttyd(self, use_zsh: bool | None = None): - """ - Start the ttyd server - ttyd -q -W -t 'theme={"background": "black"}' zsh - - Args: - use_zsh (bool): Whether to use zsh or bash. If None, it will try to detect if zsh is available. - """ - - # First, check if ttyd is installed - try: - subprocess.run(["ttyd", "--version"], check=True, stdout=subprocess.PIPE) - except FileNotFoundError: - # pylint: disable=raise-missing-from - raise RuntimeError("ttyd is not installed. Please install it first.") - - if use_zsh is None: - # Check if we can use zsh - try: - subprocess.run(["zsh", "--version"], check=True, stdout=subprocess.PIPE) - use_zsh = True - except FileNotFoundError: - use_zsh = False - - command = [ - "ttyd", - "-p", - "0", - "-W", - "-t", - 'theme={"background": "black"}', - "-c", - f"user:{self._token}", - ] - if use_zsh: - command.append("zsh") - else: - command.append("bash") - - # Start the ttyd server - self._server_process = subprocess.Popen( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - - self._wait_for_server_port() - - self._server_process.stdout.close() - self._server_process.stderr.close() - - def _wait_for_server_port(self, timeout: float = 10): - """ - Wait for the ttyd server to start and get the port number. - - Args: - timeout (float): The timeout in seconds to wait for the server to start. - """ - start_time = time.time() - while True: - output = self._server_process.stderr.readline() - if output == b"" and self._server_process.poll() is not None: - break - if not output: - continue - - output = output.decode("utf-8").strip() - if "Listening on" in output: - # Extract the port number from the output - self._server_port = int(output.split(":")[-1]) - logger.info(f"ttyd server started on port {self._server_port}") - break - if time.time() - start_time > timeout: - raise TimeoutError( - "Timeout waiting for ttyd server to start. Please check if ttyd is installed and available in your PATH." - ) - - def cleanup(self): - """ - Clean up the registry by removing any instances that are no longer valid. - """ - for gui_id, weak_ref in list(self._instances.items()): - if weak_ref() is None: - del self._instances[gui_id] - - if not self._instances and self._server_process: - # If no instances are left, terminate the server process - self._server_process.terminate() - self._server_process = None - self._server_port = None - logger.info("ttyd server terminated") - - def unregister(self, instance: WebConsole): - """ - Unregister an instance of WebConsole. - - Args: - instance (WebConsole): The instance to unregister. - """ - if instance.gui_id in self._instances: - del self._instances[instance.gui_id] - - if instance._unique_id: - self._unregister_page(instance._unique_id, instance.gui_id) - - self.cleanup() - - def _register_page(self, instance: WebConsole): - """ - Register a page in the registry. Please note that this does not transfer ownership - for already existing pages; it simply records which widget currently owns the page. - Use transfer_page_ownership to change ownership. - - Args: - instance (WebConsole): The instance to register. - """ - - unique_id = instance._unique_id - gui_id = instance.gui_id - - if unique_id is None: - return - - if unique_id not in self._page_registry: - page = BECWebEnginePage() - page.authenticationRequired.connect(instance._authenticate) - self._page_registry[unique_id] = PageOwnerInfo( - owner_gui_id=gui_id, widget_ids=[gui_id], page=page - ) - logger.info(f"Registered new page {unique_id} for {gui_id}") - return - - if gui_id not in self._page_registry[unique_id].widget_ids: - self._page_registry[unique_id].widget_ids.append(gui_id) - - def _unregister_page(self, unique_id: str, gui_id: str): - """ - Unregister a page from the registry. - - Args: - unique_id (str): The unique identifier for the page. - gui_id (str): The GUI ID of the widget. - """ - if unique_id not in self._page_registry: - return - page_info = self._page_registry[unique_id] - if gui_id in page_info.widget_ids: - page_info.widget_ids.remove(gui_id) - if page_info.owner_gui_id == gui_id: - page_info.owner_gui_id = None - if not page_info.widget_ids: - if page_info.page: - page_info.page.deleteLater() - del self._page_registry[unique_id] - - logger.info(f"Unregistered page {unique_id} for {gui_id}") - - def get_page_info(self, unique_id: str) -> PageOwnerInfo | None: - """ - Get a page from the registry. - - Args: - unique_id (str): The unique identifier for the page. - - Returns: - PageOwnerInfo | None: The page info if found, None otherwise. - """ - if unique_id not in self._page_registry: - return None - return self._page_registry[unique_id] - - def take_page_ownership(self, unique_id: str, new_owner_gui_id: str) -> QWebEnginePage | None: - """ - Transfer ownership of a page to a new owner. - - Args: - unique_id (str): The unique identifier for the page. - new_owner_gui_id (str): The GUI ID of the new owner. - - Returns: - QWebEnginePage | None: The page if ownership transfer was successful, None otherwise. - """ - if unique_id not in self._page_registry: - logger.warning(f"Page {unique_id} not found in registry") - return None - - page_info = self._page_registry[unique_id] - old_owner_gui_id = page_info.owner_gui_id - if old_owner_gui_id: - old_owner_ref = self._instances.get(old_owner_gui_id) - if old_owner_ref: - old_owner_instance = old_owner_ref() - if old_owner_instance: - old_owner_instance.yield_ownership() - page_info.owner_gui_id = new_owner_gui_id - - logger.info(f"Transferred ownership of page {unique_id} to {new_owner_gui_id}") - return page_info.page - - def yield_ownership(self, gui_id: str) -> bool: - """ - Yield ownership of a page without destroying it. The page remains in the - registry with no owner, available for another widget to claim. - - Args: - gui_id (str): The GUI ID of the widget yielding ownership. - - Returns: - bool: True if ownership was yielded, False otherwise. - """ - if gui_id not in self._instances: - return False - - instance = self._instances[gui_id]() - if instance is None: - return False - - unique_id = instance._unique_id - if unique_id is None: - return False - - if unique_id not in self._page_registry: - return False - - page_owner_info = self._page_registry[unique_id] - if page_owner_info.owner_gui_id != gui_id: - return False - - page_owner_info.owner_gui_id = None - return True - - def owner_is_visible(self, unique_id: str) -> bool: - """ - Check if the owner of a page is currently visible. - - Args: - unique_id (str): The unique identifier for the page. - Returns: - bool: True if the owner is visible, False otherwise. - """ - page_info = self.get_page_info(unique_id) - if page_info is None or page_info.owner_gui_id is None: - return False - - owner_ref = self._instances.get(page_info.owner_gui_id) - if owner_ref is None: - return False - - owner_instance = owner_ref() - if owner_instance is None: - return False - - return owner_instance.isVisible() - - -_web_console_registry = WebConsoleRegistry() - - -def suppress_qt_messages(type_, context, msg): - if context.category in ["js", "default"]: - return - print(msg) - - -qInstallMessageHandler(suppress_qt_messages) - - -class BECWebEnginePage(QWebEnginePage): - def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID): - logger.info(f"[JS Console] {level.name} at line {lineNumber} in {sourceID}: {message}") - - -class WebConsole(BECWidget, QWidget): - """ - A simple widget to display a website - """ - - _js_callback = Signal(bool) - initialized = Signal() - - PLUGIN = True - ICON_NAME = "terminal" - - def __init__( - self, - parent=None, - config=None, - client=None, - gui_id=None, - startup_cmd: str | None = None, - is_bec_shell: bool = False, - unique_id: str | None = None, - **kwargs, - ): - super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - self._mode = ConsoleMode.INACTIVE - self._is_bec_shell = is_bec_shell - self._startup_cmd = startup_cmd - self._is_initialized = False - self._unique_id = unique_id - self.page = None # Will be set in _set_up_page - - self._startup_timer = QTimer() - self._startup_timer.setInterval(500) - self._startup_timer.timeout.connect(self._check_page_ready) - self._startup_timer.start() - self._js_callback.connect(self._on_js_callback) - - self._set_up_page() - - def _set_up_page(self): - """ - Set up the web page and UI elements. - """ - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - self.browser = QWebEngineView(self) - - layout.addWidget(self.browser) - self.setLayout(layout) - - # prepare overlay - self.overlay = QWidget(self) - layout = QVBoxLayout(self.overlay) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - label = QLabel("Click to activate terminal", self.overlay) - layout.addWidget(label) - self.overlay.hide() - - _web_console_registry.register(self) - self._token = _web_console_registry._token - - # If no unique_id is provided, create a new page - if not self._unique_id: - self.page = BECWebEnginePage(self) - self.page.authenticationRequired.connect(self._authenticate) - self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}")) - self.browser.setPage(self.page) - self._set_mode(ConsoleMode.ACTIVE) - return - - # Try to get the page from the registry - page_info = _web_console_registry.get_page_info(self._unique_id) - if page_info and page_info.page: - self.page = page_info.page - if not page_info.owner_gui_id or page_info.owner_gui_id == self.gui_id: - self.browser.setPage(self.page) - # Only set URL if this is a newly created page (no URL set yet) - if self.page.url().isEmpty(): - self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}")) - else: - # We have an existing page, so we don't need the startup timer - self._startup_timer.stop() - if page_info.owner_gui_id != self.gui_id: - self._set_mode(ConsoleMode.INACTIVE) - else: - self._set_mode(ConsoleMode.ACTIVE) - - def _set_mode(self, mode: ConsoleMode): - """ - Set the mode of the web console. - - Args: - mode (ConsoleMode): The mode to set. - """ - if not self._unique_id: - # For non-unique_id consoles, always active - mode = ConsoleMode.ACTIVE - - self._mode = mode - match mode: - case ConsoleMode.ACTIVE: - self.browser.setVisible(True) - self.overlay.hide() - case ConsoleMode.INACTIVE: - self.browser.setVisible(False) - self.overlay.show() - case ConsoleMode.HIDDEN: - self.browser.setVisible(False) - self.overlay.hide() - - def _check_page_ready(self): - """ - Check if the page is ready and stop the timer if it is. - """ - if not self.page or self.page.isLoading(): - return - - self.page.runJavaScript("window.term !== undefined", self._js_callback.emit) - - def _on_js_callback(self, ready: bool): - """ - Callback for when the JavaScript is ready. - """ - if not ready: - return - self._is_initialized = True - self._startup_timer.stop() - if self.startup_cmd: - if self._unique_id: - page_info = _web_console_registry.get_page_info(self._unique_id) - if page_info is None: - return - if not page_info.initialized: - page_info.initialized = True - self.write(self.startup_cmd) - else: - self.write(self.startup_cmd) - self.initialized.emit() - - @property - def startup_cmd(self): - """ - Get the startup command for the web console. - """ - if self._is_bec_shell: - if self.bec_dispatcher.cli_server is None: - return "bec --nogui" - return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}" - return self._startup_cmd - - @startup_cmd.setter - def startup_cmd(self, cmd: str): - """ - Set the startup command for the web console. - """ - if not isinstance(cmd, str): - raise ValueError("Startup command must be a string.") - self._startup_cmd = cmd - - def write(self, data: str, send_return: bool = True): - """ - Send data to the web page - - Args: - data (str): The data to send. - send_return (bool): Whether to send a return after the data. - """ - cmd = f"window.term.paste({json.dumps(data)});" - if self.page is None: - logger.warning("Cannot write to web console: page is not initialized.") - return - self.page.runJavaScript(cmd) - if send_return: - self.send_return() - - def take_page_ownership(self, unique_id: str | None = None): - """ - Take ownership of a web page from the registry. This will transfer the page - from its current owner (if any) to this widget. - - Args: - unique_id (str): The unique identifier of the page to take ownership of. - If None, uses this widget's unique_id. - """ - if unique_id is None: - unique_id = self._unique_id - - if not unique_id: - logger.warning("Cannot take page ownership without a unique_id") - return - - # Get the page from registry - page = _web_console_registry.take_page_ownership(unique_id, self.gui_id) - - if not page: - logger.warning(f"Page {unique_id} not found in registry") - return - - self.page = page - self.browser.setPage(page) - self._set_mode(ConsoleMode.ACTIVE) - logger.info(f"Widget {self.gui_id} took ownership of page {unique_id}") - - def _on_ownership_lost(self): - """ - Called when this widget loses ownership of its page. - Displays the overlay and hides the browser. - """ - self._set_mode(ConsoleMode.INACTIVE) - logger.info(f"Widget {self.gui_id} lost ownership of page {self._unique_id}") - - def yield_ownership(self): - """ - Yield ownership of the page. The page remains in the registry with no owner, - available for another widget to claim. This is automatically called when the - widget becomes hidden. - """ - if not self._unique_id: - return - success = _web_console_registry.yield_ownership(self.gui_id) - if success: - self._on_ownership_lost() - logger.info(f"Widget {self.gui_id} yielded ownership of page {self._unique_id}") - - def has_ownership(self) -> bool: - """ - Check if this widget currently has ownership of a page. - - Returns: - bool: True if this widget owns a page, False otherwise. - """ - if not self._unique_id: - return False - page_info = _web_console_registry.get_page_info(self._unique_id) - if page_info is None: - return False - return page_info.owner_gui_id == self.gui_id - - def hideEvent(self, event): - """ - Called when the widget is hidden. Automatically yields ownership. - """ - if self.has_ownership(): - self.yield_ownership() - self._set_mode(ConsoleMode.HIDDEN) - super().hideEvent(event) - - def showEvent(self, event): - """ - Called when the widget is shown. Updates UI state based on ownership. - """ - super().showEvent(event) - if self._unique_id and not self.has_ownership(): - # Take ownership if the page does not have an owner or - # the owner is not visible - page_info = _web_console_registry.get_page_info(self._unique_id) - if page_info is None: - self._set_mode(ConsoleMode.INACTIVE) - return - if page_info.owner_gui_id is None or not _web_console_registry.owner_is_visible( - self._unique_id - ): - self.take_page_ownership(self._unique_id) - return - if page_info.owner_gui_id != self.gui_id: - self._set_mode(ConsoleMode.INACTIVE) - return - - def resizeEvent(self, event: QResizeEvent) -> None: - super().resizeEvent(event) - self.overlay.resize(event.size()) - - def mousePressEvent(self, event: QMouseEvent) -> None: - if event.button() == Qt.MouseButton.LeftButton and not self.has_ownership(): - self.take_page_ownership(self._unique_id) - event.accept() - return - return super().mousePressEvent(event) - - def _authenticate(self, _, auth): - """ - Authenticate the request with the provided username and password. - """ - auth.setUser("user") - auth.setPassword(self._token) - - def send_return(self): - """ - Send return to the web page - """ - self.page.runJavaScript( - "document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 13}))" - ) - - def send_ctrl_c(self): - """ - Send Ctrl+C to the web page - """ - self.page.runJavaScript( - "document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))" - ) - - def set_readonly(self, readonly: bool): - """ - Set the web console to read-only mode. - """ - if not isinstance(readonly, bool): - raise ValueError("Readonly must be a boolean.") - self.setEnabled(not readonly) - - def cleanup(self): - """ - Clean up the registry by removing any instances that are no longer valid. - """ - self._startup_timer.stop() - _web_console_registry.unregister(self) - super().cleanup() - - -class BECShell(WebConsole): - """ - A WebConsole pre-configured to run the BEC shell. - We cannot simply expose the web console properties to Qt as we need to have a deterministic - startup behavior for sharing the same shell instance across multiple widgets. - """ - - ICON_NAME = "hub" - - def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): - super().__init__( - parent=parent, - config=config, - client=client, - gui_id=gui_id, - is_bec_shell=True, - unique_id="bec_shell", - **kwargs, - ) - - -if __name__ == "__main__": # pragma: no cover - import sys - - app = QApplication(sys.argv) - widget = QTabWidget() - - # Create two consoles with different unique_ids - web_console1 = WebConsole(startup_cmd="bec --nogui", unique_id="console1") - web_console2 = WebConsole(startup_cmd="htop") - web_console3 = WebConsole(startup_cmd="bec --nogui", unique_id="console1") - widget.addTab(web_console1, "Console 1") - widget.addTab(web_console2, "Console 2") - widget.addTab(web_console3, "Console 3 -- mirror of Console 1") - widget.show() - - # Demonstrate page sharing: - # After initialization, web_console2 can take ownership of console1's page: - # web_console2.take_page_ownership("console1") - - widget.resize(800, 600) - - def _close_cons1(): - web_console2.close() - web_console2.deleteLater() - - # QTimer.singleShot(3000, _close_cons1) - - sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/editors/web_console/web_console.pyproject b/bec_widgets/widgets/editors/web_console/web_console.pyproject deleted file mode 100644 index 786a751f..00000000 --- a/bec_widgets/widgets/editors/web_console/web_console.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['web_console.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/utility/bec_term/__init__.py b/bec_widgets/widgets/utility/bec_term/__init__.py new file mode 100644 index 00000000..66568400 --- /dev/null +++ b/bec_widgets/widgets/utility/bec_term/__init__.py @@ -0,0 +1,11 @@ +if __name__ == "__main__": # pragma: no cover + import sys + + from pyside6_qtermwidget import QTermWidget # pylint: disable=ungrouped-imports + from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports + + app = QApplication(sys.argv) + widget = QTermWidget() + + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/utility/bec_term/protocol.py b/bec_widgets/widgets/utility/bec_term/protocol.py new file mode 100644 index 00000000..4300009c --- /dev/null +++ b/bec_widgets/widgets/utility/bec_term/protocol.py @@ -0,0 +1,8 @@ +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class BecTerminal(Protocol): + """Implementors of this protocol must also be subclasses of QWidget""" + + def write(self, text: str, add_newline: bool = True): ... diff --git a/bec_widgets/widgets/utility/bec_term/qtermwidget_wrapper.py b/bec_widgets/widgets/utility/bec_term/qtermwidget_wrapper.py new file mode 100644 index 00000000..6f4653e8 --- /dev/null +++ b/bec_widgets/widgets/utility/bec_term/qtermwidget_wrapper.py @@ -0,0 +1,241 @@ +"""A wrapper for the optional external dependency pyside6_qtermwidget. +Simply displays a message in a QLabel if the dependency is not installed.""" + +import os +from functools import wraps +from typing import Sequence + +from qtpy.QtCore import QIODevice, QPoint, QSize, QUrl, Signal # type: ignore +from qtpy.QtGui import QAction, QFont, QKeyEvent, QResizeEvent, Qt # type: ignore +from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + +try: + from pyside6_qtermwidget import QTermWidget +except ImportError: + QTermWidget = None + + +def _forward(func): + """Apply to a private method to forward the call to the method on QTermWidget with the same name, + (with leading '_' removed) if it is defined, otherwise do nothing.""" + + @wraps(func) + def wrapper(self, *args, **kwargs): + target = getattr(self, "_main_widget") + method = getattr(target, func.__name__[1:]) + if QTermWidget: + return method(*args, **kwargs) + else: + ... + + return wrapper + + +class BecQTerm(QWidget): + activity = Signal() + bell = Signal(str) + copy_available = Signal(bool) + current_directory_changed = Signal(str) + finished = Signal() + profile_changed = Signal(str) + received_data = Signal(str) + silence = Signal() + term_got_focus = Signal() + term_key_pressed = Signal(QKeyEvent) + term_lost_focus = Signal() + title_changed = Signal() + url_activated = Signal(QUrl, bool) + + def __init__(self, /, parent: QWidget | None = None, **kwargs) -> None: + super().__init__(parent) + self._layout = QVBoxLayout() + self.setLayout(self._layout) + if QTermWidget: + self._main_widget = QTermWidget(parent=self) + self.activity.connect(self._main_widget.activity) + self.bell.connect(self._main_widget.bell) + self.copy_available.connect(self._main_widget.copyAvailable) + self.current_directory_changed.connect(self._main_widget.currentDirectoryChanged) + self.finished.connect(self._main_widget.finished) + self.profile_changed.connect(self._main_widget.profileChanged) + self.received_data.connect(self._main_widget.receivedData) + self.silence.connect(self._main_widget.silence) + self.term_got_focus.connect(self._main_widget.termGetFocus) + self.term_key_pressed.connect(self._main_widget.termKeyPressed) + self.term_lost_focus.connect(self._main_widget.termLostFocus) + self.title_changed.connect(self._main_widget.titleChanged) + self.url_activated.connect(self._main_widget.urlActivated) + self._setEnvironment([f"{k}={v}" for k, v in os.environ.items()]) + self._setColorScheme("Solarized") + else: + self._layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._main_widget = QLabel("pyside6_qterminal is not installed!") + + self._layout.addWidget(self._main_widget) + + def write(self, text: str, add_newline: bool = True): + if add_newline: + text += "\n" + self._sendText(text) + + # automatically forwarded to the widget only if it exists + @_forward + def _addCustomColorSchemeDir(self, custom_dir: str, /) -> None: ... + @_forward + def _autoHideMouseAfter(self, delay: int, /) -> None: ... + @_forward + def _availableColorSchemes(self) -> list[str]: ... + @_forward + def _availableKeyBindings(self) -> list[str]: ... + @_forward + def _bracketText(self, text: str, /) -> None: ... + @_forward + def _bracketedPasteModeIsDisabled(self, /) -> bool: ... + @_forward + def _changeDir(self, dir: str, /) -> None: ... + @_forward + def _clear(self, /) -> None: ... + @_forward + def _clearCustomKeyBindingsDir(self, /) -> None: ... + @_forward + def _copyClipboard(self, /) -> None: ... + @_forward + def _disableBracketedPasteMode(self, disable: bool, /) -> None: ... + @_forward + def _filterActions(self, position: QPoint, /) -> list[QAction]: ... + @_forward + def _flowControlEnabled(self, /) -> bool: ... + @_forward + def _getAvailableColorSchemes(self, /) -> list[str]: ... + @_forward + def _getForegroundProcessId(self, /) -> int: ... + @_forward + def _getMargin(self, /) -> int: ... + @_forward + def _getPtySlaveFd(self, /) -> int: ... + @_forward + def _getSelectionEnd(self, row: int, column: int, /) -> None: ... + @_forward + def _getSelectionStart(self, row: int, column: int, /) -> None: ... + @_forward + def _getShellPID(self, /) -> int: ... + @_forward + def _getTerminalFont(self, /) -> QFont: ... + @_forward + def _historyLinesCount(self, /) -> int: ... + @_forward + def _historySize(self, /) -> int: ... + @_forward + def _icon(self, /) -> str: ... + @_forward + def _isBidiEnabled(self, /) -> bool: ... + @_forward + def _isTitleChanged(self, /) -> bool: ... + @_forward + def _keyBindings(self, /) -> str: ... + @_forward + def _pasteClipboard(self, /) -> None: ... + @_forward + def _pasteSelection(self, /) -> None: ... + @_forward + def _resizeEvent(self, arg__1: QResizeEvent, /) -> None: ... + @_forward + def _saveHistory(self, device: QIODevice, /) -> None: ... + @_forward + def _screenColumnsCount(self, /) -> int: ... + @_forward + def _screenLinesCount(self, /) -> int: ... + @_forward + def _scrollToEnd(self, /) -> None: ... + @_forward + def _selectedText(self, /, preserveLineBreaks: bool = ...) -> str: ... + @_forward + def _selectionChanged(self, textSelected: bool, /) -> None: ... + @_forward + def _sendKeyEvent(self, e: QKeyEvent, /) -> None: ... + @_forward + def _sendText(self, text: str, /) -> None: ... + @_forward + def _sessionFinished(self, /) -> None: ... + @_forward + def _setArgs(self, args: Sequence[str], /) -> None: ... + @_forward + def _setAutoClose(self, arg__1: bool, /) -> None: ... + @_forward + def _setBidiEnabled(self, enabled: bool, /) -> None: ... + @_forward + def _setBlinkingCursor(self, blink: bool, /) -> None: ... + @_forward + def _setBoldIntense(self, boldIntense: bool, /) -> None: ... + @_forward + def _setColorScheme(self, name: str, /) -> None: ... + @_forward + def _setConfirmMultilinePaste(self, confirmMultilinePaste: bool, /) -> None: ... + @_forward + def _setCustomKeyBindingsDir(self, custom_dir: str, /) -> None: ... + @_forward + def _setDrawLineChars(self, drawLineChars: bool, /) -> None: ... + @_forward + def _setEnvironment(self, environment: Sequence[str], /) -> None: ... + @_forward + def _setFlowControlEnabled(self, enabled: bool, /) -> None: ... + @_forward + def _setFlowControlWarningEnabled(self, enabled: bool, /) -> None: ... + @_forward + def _setHistorySize(self, lines: int, /) -> None: ... + @_forward + def _setKeyBindings(self, kb: str, /) -> None: ... + @_forward + def _setMargin(self, arg__1: int, /) -> None: ... + @_forward + def _setMonitorActivity(self, arg__1: bool, /) -> None: ... + @_forward + def _setMonitorSilence(self, arg__1: bool, /) -> None: ... + @_forward + def _setMotionAfterPasting(self, arg__1: int, /) -> None: ... + @_forward + def _setSelectionEnd(self, row: int, column: int, /) -> None: ... + @_forward + def _setSelectionStart(self, row: int, column: int, /) -> None: ... + @_forward + def _setShellProgram(self, program: str, /) -> None: ... + @_forward + def _setSilenceTimeout(self, seconds: int, /) -> None: ... + @_forward + def _setSize(self, arg__1: QSize, /) -> None: ... + @_forward + def _setTerminalBackgroundImage(self, backgroundImage: str, /) -> None: ... + @_forward + def _setTerminalBackgroundMode(self, mode: int, /) -> None: ... + @_forward + def _setTerminalFont(self, font: QFont | str | Sequence[str], /) -> None: ... + @_forward + def _setTerminalOpacity(self, level: float, /) -> None: ... + @_forward + def _setTerminalSizeHint(self, enabled: bool, /) -> None: ... + @_forward + def _setTrimPastedTrailingNewlines(self, trimPastedTrailingNewlines: bool, /) -> None: ... + @_forward + def _setWordCharacters(self, chars: str, /) -> None: ... + @_forward + def _setWorkingDirectory(self, dir: str, /) -> None: ... + @_forward + def _sizeHint(self, /) -> QSize: ... + @_forward + def _startShellProgram(self, /) -> None: ... + @_forward + def _startTerminalTeletype(self, /) -> None: ... + @_forward + def _terminalSizeHint(self, /) -> bool: ... + @_forward + def _title(self, /) -> str: ... + @_forward + def _toggleShowSearchBar(self, /) -> None: ... + @_forward + def _wordCharacters(self, /) -> str: ... + @_forward + def _workingDirectory(self, /) -> str: ... + @_forward + def _zoomIn(self, /) -> None: ... + @_forward + def _zoomOut(self, /) -> None: ... diff --git a/bec_widgets/widgets/utility/bec_term/util.py b/bec_widgets/widgets/utility/bec_term/util.py new file mode 100644 index 00000000..d27ed108 --- /dev/null +++ b/bec_widgets/widgets/utility/bec_term/util.py @@ -0,0 +1,6 @@ +from bec_widgets.widgets.utility.bec_term.protocol import BecTerminal +from bec_widgets.widgets.utility.bec_term.qtermwidget_wrapper import BecQTerm + + +def get_current_bec_term_class() -> type[BecTerminal]: + return BecQTerm diff --git a/pyproject.toml b/pyproject.toml index 8161aad5..4806fe7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,54 +1,34 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - [project] name = "bec_widgets" version = "3.4.4" description = "BEC Widgets" requires-python = ">=3.11" classifiers = [ - "Development Status :: 3 - Alpha", - "Programming Language :: Python :: 3", - "Topic :: Scientific/Engineering", + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering", ] dependencies = [ - "bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console - "bec_lib~=3.107,>=3.107.2", - "bec_qthemes~=1.0, >=1.3.4", - "black>=26,<27", # needed for bw-generate-cli - "isort>=5.13, <9.0", # needed for bw-generate-cli - "ophyd_devices~=1.29, >=1.29.1", - "pydantic~=2.0", - "pyqtgraph==0.13.7", - "PySide6==6.9.0", - "qtconsole~=5.5, >=5.5.1", # needed for jupyter console - "qtpy~=2.4", - "thefuzz~=0.22", - "qtmonaco~=0.8, >=0.8.1", - "darkdetect~=0.8", - "PySide6-QtAds==4.4.0", - "pylsp-bec~=1.2", - "copier~=9.7", - "typer~=0.15", - "markdown~=3.9", - "PyJWT~=2.9", -] - - -[project.optional-dependencies] -dev = [ - "coverage~=7.0", - "fakeredis~=2.23, >=2.23.2", - "pytest-bec-e2e>=2.21.4, <=4.0", - "pytest-qt~=4.4", - "pytest-random-order~=1.1", - "pytest-timeout~=2.2", - "pytest-xvfb~=3.0", - "pytest~=8.0", - "pytest-cov~=6.1.1", - "watchdog~=6.0", - "pre_commit~=4.2", + "PyJWT~=2.9", + "PySide6==6.9.0", + "PySide6-QtAds==4.4.0", + "bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console + "bec_lib~=3.107,>=3.107.2", + "bec_qthemes~=1.0, >=1.3.4", + "black>=26,<27", # needed for bw-generate-cli + "copier~=9.7", + "darkdetect~=0.8", + "isort>=5.13, <9.0", # needed for bw-generate-cli + "markdown~=3.9", + "ophyd_devices~=1.29, >=1.29.1", + "pydantic~=2.0", + "pylsp-bec~=1.2", + "pyqtgraph==0.13.7", + "qtconsole~=5.5, >=5.5.1", # needed for jupyter console + "qtmonaco~=0.8, >=0.8.1", + "qtpy~=2.4", + "thefuzz~=0.22", + "typer~=0.15", ] [project.urls] @@ -56,10 +36,44 @@ dev = [ Homepage = "https://gitlab.psi.ch/bec/bec_widgets" [project.scripts] -bw-generate-cli = "bec_widgets.cli.generate_cli:main" -bec-gui-server = "bec_widgets.cli.server:main" -bec-designer = "bec_widgets.utils.bec_designer:main" bec-app = "bec_widgets.applications.main_app:main" +bec-designer = "bec_widgets.utils.bec_designer:main" +bec-gui-server = "bec_widgets.cli.server:main" +bw-generate-cli = "bec_widgets.cli.generate_cli:main" + +[project.optional-dependencies] +dev = [ + "coverage~=7.0", + "fakeredis~=2.23, >=2.23.2", + "pytest-bec-e2e>=2.21.4, <=4.0", + "pytest-qt~=4.4", + "pytest-random-order~=1.1", + "pytest-timeout~=2.2", + "pytest-xvfb~=3.0", + "pytest~=8.0", + "pytest-cov~=6.1.1", + "watchdog~=6.0", + "pre_commit~=4.2", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.black] +line-length = 100 +skip-magic-trailing-comma = true + +[tool.coverage.report] +skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "return NotImplemented", + "raise NotImplementedError", + "\\.\\.\\.", + 'if __name__ == "__main__":', +] [tool.hatch.build.targets.wheel] include = ["*"] @@ -69,10 +83,6 @@ exclude = ["docs/**", "tests/**"] include = ["*"] exclude = ["docs/**", "tests/**"] -[tool.black] -line-length = 100 -skip-magic-trailing-comma = true - [tool.isort] profile = "black" line_length = 100 @@ -80,6 +90,12 @@ multi_line_output = 3 include_trailing_comma = true known_first_party = ["bec_widgets"] +[tool.ruff] +line-length = 100 + +[tool.ruff.format] +skip-magic-trailing-comma = true + [tool.semantic_release] build_command = "pip install build wheel && python -m build" version_toml = ["pyproject.toml:project.version"] @@ -90,16 +106,16 @@ default = "semantic-release " [tool.semantic_release.commit_parser_options] allowed_tags = [ - "build", - "chore", - "ci", - "docs", - "feat", - "fix", - "perf", - "style", - "refactor", - "test", + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "style", + "refactor", + "test", ] minor_tags = ["feat"] patch_tags = ["fix", "perf"] @@ -116,14 +132,3 @@ env = "GH_TOKEN" [tool.semantic_release.publish] dist_glob_patterns = ["dist/*"] upload_to_vcs_release = true - -[tool.coverage.report] -skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report -exclude_lines = [ - "pragma: no cover", - "if TYPE_CHECKING:", - "return NotImplemented", - "raise NotImplementedError", - "\\.\\.\\.", - 'if __name__ == "__main__":', -] diff --git a/tests/end-2-end/test_rpc_widgets_e2e.py b/tests/end-2-end/test_rpc_widgets_e2e.py index 31b818b4..19e8109f 100644 --- a/tests/end-2-end/test_rpc_widgets_e2e.py +++ b/tests/end-2-end/test_rpc_widgets_e2e.py @@ -93,8 +93,8 @@ def test_available_widgets(qtbot, connected_client_gui_obj): if object_name == "BECShell": continue - # Skip WebConsole as ttyd is not installed - if object_name == "WebConsole": + # Skip BecConsole as ttyd is not installed + if object_name == "BecConsole": continue ############################# diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py index ef2c3792..be4b1937 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -918,7 +918,7 @@ class TestToolbarFunctionality: action.trigger() if action_name == "terminal": mock_new.assert_called_once_with( - widget="WebConsole", closable=True, startup_cmd=None + widget="BecConsole", closable=True, startup_cmd=None ) else: mock_new.assert_called_once_with(widget=widget_type) @@ -2272,7 +2272,7 @@ class TestFlatToolbarActions: "flat_queue": "BECQueue", "flat_status": "BECStatusBox", "flat_progress_bar": "RingProgressBar", - "flat_terminal": "WebConsole", + "flat_terminal": "BecConsole", "flat_bec_shell": "BECShell", "flat_sbb_monitor": "SBBMonitor", } diff --git a/tests/unit_tests/test_web_console.py b/tests/unit_tests/test_web_console.py index c27eb516..da9676ec 100644 --- a/tests/unit_tests/test_web_console.py +++ b/tests/unit_tests/test_web_console.py @@ -5,11 +5,11 @@ from qtpy.QtCore import Qt from qtpy.QtGui import QHideEvent from qtpy.QtNetwork import QAuthenticator -from bec_widgets.widgets.editors.web_console.web_console import ( +from bec_widgets.widgets.editors.bec_console.bec_console import ( + BecConsole, BECShell, ConsoleMode, - WebConsole, - _web_console_registry, + _bec_console_registry, ) from .client_mocks import mocked_client @@ -19,19 +19,19 @@ from .client_mocks import mocked_client def mocked_server_startup(): """Mock the web console server startup process.""" with mock.patch( - "bec_widgets.widgets.editors.web_console.web_console.subprocess" + "bec_widgets.widgets.editors.bec_console.bec_console.subprocess" ) as mock_subprocess: - with mock.patch.object(_web_console_registry, "_wait_for_server_port"): - _web_console_registry._server_port = 12345 + with mock.patch.object(_bec_console_registry, "_wait_for_server_port"): + _bec_console_registry._server_port = 12345 yield mock_subprocess def static_console(qtbot, client, unique_id: str | None = None): - """Fixture to provide a static unique_id for WebConsole tests.""" + """Fixture to provide a static unique_id for BecConsole tests.""" if unique_id is None: - widget = WebConsole(client=client) + widget = BecConsole(client=client) else: - widget = WebConsole(client=client, unique_id=unique_id) + widget = BecConsole(client=client, terminal_id=unique_id) qtbot.addWidget(widget) qtbot.waitExposed(widget) return widget @@ -39,7 +39,7 @@ def static_console(qtbot, client, unique_id: str | None = None): @pytest.fixture def console_widget(qtbot, mocked_client, mocked_server_startup): - """Create a WebConsole widget with mocked server startup.""" + """Create a BecConsole widget with mocked server startup.""" yield static_console(qtbot, mocked_client) @@ -54,26 +54,26 @@ def bec_shell_widget(qtbot, mocked_client, mocked_server_startup): @pytest.fixture def console_widget_with_static_id(qtbot, mocked_client, mocked_server_startup): - """Create a WebConsole widget with a static unique ID.""" + """Create a BecConsole widget with a static unique ID.""" yield static_console(qtbot, mocked_client, unique_id="test_console") @pytest.fixture def two_console_widgets_same_id(qtbot, mocked_client, mocked_server_startup): - """Create two WebConsole widgets sharing the same unique ID.""" + """Create two BecConsole widgets sharing the same unique ID.""" widget1 = static_console(qtbot, mocked_client, unique_id="shared_console") widget2 = static_console(qtbot, mocked_client, unique_id="shared_console") yield widget1, widget2 -def test_web_console_widget_initialization(console_widget): +def test_bec_console_widget_initialization(console_widget): assert ( console_widget.page.url().toString() - == f"http://localhost:{_web_console_registry._server_port}" + == f"http://localhost:{_bec_console_registry._server_port}" ) -def test_web_console_write(console_widget): +def test_bec_console_write(console_widget): # Test the write method with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js: console_widget.write("Hello, World!") @@ -81,7 +81,7 @@ def test_web_console_write(console_widget): assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls -def test_web_console_write_no_return(console_widget): +def test_bec_console_write_no_return(console_widget): # Test the write method with send_return=False with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js: console_widget.write("Hello, World!", send_return=False) @@ -90,7 +90,7 @@ def test_web_console_write_no_return(console_widget): assert mock_run_js.call_count == 1 -def test_web_console_send_return(console_widget): +def test_bec_console_send_return(console_widget): # Test the send_return method with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js: console_widget.send_return() @@ -100,7 +100,7 @@ def test_web_console_send_return(console_widget): assert mock_run_js.call_count == 1 -def test_web_console_send_ctrl_c(console_widget): +def test_bec_console_send_ctrl_c(console_widget): # Test the send_ctrl_c method with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js: console_widget.send_ctrl_c() @@ -110,31 +110,31 @@ def test_web_console_send_ctrl_c(console_widget): assert mock_run_js.call_count == 1 -def test_web_console_authenticate(console_widget): +def test_bec_console_authenticate(console_widget): # Test the _authenticate method - token = _web_console_registry._token + token = _bec_console_registry._token mock_auth = mock.MagicMock(spec=QAuthenticator) console_widget._authenticate(None, mock_auth) mock_auth.setUser.assert_called_once_with("user") mock_auth.setPassword.assert_called_once_with(token) -def test_web_console_registry_wait_for_server_port(): +def test_bec_console_registry_wait_for_server_port(): # Test the _wait_for_server_port method - with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess: + with mock.patch.object(_bec_console_registry, "_server_process") as mock_subprocess: mock_subprocess.stderr.readline.side_effect = [b"Starting", b"Listening on port: 12345"] - _web_console_registry._wait_for_server_port() - assert _web_console_registry._server_port == 12345 + _bec_console_registry._wait_for_server_port() + assert _bec_console_registry._server_port == 12345 -def test_web_console_registry_wait_for_server_port_timeout(): +def test_bec_console_registry_wait_for_server_port_timeout(): # Test the _wait_for_server_port method with timeout - with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess: + with mock.patch.object(_bec_console_registry, "_server_process") as mock_subprocess: with pytest.raises(TimeoutError): - _web_console_registry._wait_for_server_port(timeout=0.1) + _bec_console_registry._wait_for_server_port(timeout=0.1) -def test_web_console_startup_command_execution(console_widget, qtbot): +def test_bec_console_startup_command_execution(console_widget, qtbot): """Test that the startup command is triggered after successful initialization.""" # Set a custom startup command console_widget.startup_cmd = "test startup command" @@ -196,7 +196,7 @@ def test_bec_shell_startup_contains_gui_id(bec_shell_widget): assert bec_shell.startup_cmd == "bec --gui-id test_gui_id" -def test_web_console_set_readonly(console_widget): +def test_bec_console_set_readonly(console_widget): # Test the set_readonly method console_widget.set_readonly(True) assert not console_widget.isEnabled() @@ -205,31 +205,31 @@ def test_web_console_set_readonly(console_widget): assert console_widget.isEnabled() -def test_web_console_with_unique_id(console_widget_with_static_id): - """Test creating a WebConsole with a unique_id.""" +def test_bec_console_with_unique_id(console_widget_with_static_id): + """Test creating a BecConsole with a unique_id.""" widget = console_widget_with_static_id assert widget._unique_id == "test_console" - assert widget._unique_id in _web_console_registry._page_registry - page_info = _web_console_registry.get_page_info("test_console") + assert widget._unique_id in _bec_console_registry._page_registry + page_info = _bec_console_registry.get_page_info("test_console") assert page_info is not None assert page_info.owner_gui_id == widget.gui_id assert widget.gui_id in page_info.widget_ids -def test_web_console_page_sharing(two_console_widgets_same_id): +def test_bec_console_page_sharing(two_console_widgets_same_id): """Test that two widgets can share the same page using unique_id.""" widget1, widget2 = two_console_widgets_same_id # Both should reference the same page in the registry - page_info = _web_console_registry.get_page_info("shared_console") + page_info = _bec_console_registry.get_page_info("shared_console") assert page_info is not None assert widget1.gui_id in page_info.widget_ids assert widget2.gui_id in page_info.widget_ids assert widget1.page == widget2.page -def test_web_console_has_ownership(console_widget_with_static_id): +def test_bec_console_has_ownership(console_widget_with_static_id): """Test the has_ownership method.""" widget = console_widget_with_static_id @@ -237,7 +237,7 @@ def test_web_console_has_ownership(console_widget_with_static_id): assert widget.has_ownership() -def test_web_console_yield_ownership(console_widget_with_static_id): +def test_bec_console_yield_ownership(console_widget_with_static_id): """Test yielding ownership of a page.""" widget = console_widget_with_static_id @@ -248,13 +248,13 @@ def test_web_console_yield_ownership(console_widget_with_static_id): # Widget should no longer have ownership assert not widget.has_ownership() - page_info = _web_console_registry.get_page_info("test_console") + page_info = _bec_console_registry.get_page_info("test_console") assert page_info.owner_gui_id is None # Overlay should be shown assert widget._mode == ConsoleMode.INACTIVE -def test_web_console_take_page_ownership(two_console_widgets_same_id): +def test_bec_console_take_page_ownership(two_console_widgets_same_id): """Test taking ownership of a page.""" widget1, widget2 = two_console_widgets_same_id @@ -273,7 +273,7 @@ def test_web_console_take_page_ownership(two_console_widgets_same_id): assert widget1._mode == ConsoleMode.INACTIVE -def test_web_console_hide_event_yields_ownership(qtbot, console_widget_with_static_id): +def test_bec_console_hide_event_yields_ownership(qtbot, console_widget_with_static_id): """Test that hideEvent yields ownership.""" widget = console_widget_with_static_id @@ -287,11 +287,11 @@ def test_web_console_hide_event_yields_ownership(qtbot, console_widget_with_stat # Widget should have yielded ownership assert not widget.has_ownership() - page_info = _web_console_registry.get_page_info("test_console") + page_info = _bec_console_registry.get_page_info("test_console") assert page_info.owner_gui_id is None -def test_web_console_show_event_takes_ownership(console_widget_with_static_id): +def test_bec_console_show_event_takes_ownership(console_widget_with_static_id): """Test that showEvent takes ownership when page has no owner.""" widget = console_widget_with_static_id @@ -308,7 +308,7 @@ def test_web_console_show_event_takes_ownership(console_widget_with_static_id): assert not widget.overlay.isVisible() -def test_web_console_mouse_press_takes_ownership(qtbot, two_console_widgets_same_id): +def test_bec_console_mouse_press_takes_ownership(qtbot, two_console_widgets_same_id): """Test that clicking on overlay takes ownership.""" widget1, widget2 = two_console_widgets_same_id widget1.show() @@ -328,20 +328,20 @@ def test_web_console_mouse_press_takes_ownership(qtbot, two_console_widgets_same assert not widget1.has_ownership() -def test_web_console_registry_cleanup_removes_page(console_widget_with_static_id): +def test_bec_console_registry_cleanup_removes_page(console_widget_with_static_id): """Test that the registry cleans up pages when all widgets are removed.""" widget = console_widget_with_static_id - assert widget._unique_id in _web_console_registry._page_registry + assert widget._unique_id in _bec_console_registry._page_registry # Cleanup the widget widget.cleanup() # Page should be removed from registry - assert widget._unique_id not in _web_console_registry._page_registry + assert widget._unique_id not in _bec_console_registry._page_registry -def test_web_console_without_unique_id_no_page_sharing(console_widget): +def test_bec_console_without_unique_id_no_page_sharing(console_widget): """Test that widgets without unique_id don't participate in page sharing.""" widget = console_widget @@ -350,20 +350,20 @@ def test_web_console_without_unique_id_no_page_sharing(console_widget): assert not widget.has_ownership() # Should return False for non-unique widgets -def test_web_console_registry_get_page_info_nonexistent(qtbot, mocked_client): +def test_bec_console_registry_get_page_info_nonexistent(qtbot, mocked_client): """Test getting page info for a non-existent page.""" - page_info = _web_console_registry.get_page_info("nonexistent") + page_info = _bec_console_registry.get_page_info("nonexistent") assert page_info is None -def test_web_console_take_ownership_without_unique_id(console_widget): +def test_bec_console_take_ownership_without_unique_id(console_widget): """Test that take_page_ownership fails gracefully without unique_id.""" widget = console_widget # Should not crash when taking ownership without unique_id widget.take_page_ownership() -def test_web_console_yield_ownership_without_unique_id(console_widget): +def test_bec_console_yield_ownership_without_unique_id(console_widget): """Test that yield_ownership fails gracefully without unique_id.""" widget = console_widget # Should not crash when yielding ownership without unique_id @@ -372,7 +372,7 @@ def test_web_console_yield_ownership_without_unique_id(console_widget): def test_registry_yield_ownership_gui_id_not_in_instances(): """Test registry yield_ownership returns False when gui_id not in instances.""" - result = _web_console_registry.yield_ownership("nonexistent_gui_id") + result = _bec_console_registry.yield_ownership("nonexistent_gui_id") assert result is False @@ -382,9 +382,9 @@ def test_registry_yield_ownership_instance_is_none(console_widget_with_static_id gui_id = widget.gui_id # Store the gui_id and simulate the weakref being dead - _web_console_registry._instances[gui_id] = lambda: None + _bec_console_registry._consoles[gui_id] = lambda: None - result = _web_console_registry.yield_ownership(gui_id) + result = _bec_console_registry.yield_ownership(gui_id) assert result is False @@ -395,7 +395,7 @@ def test_registry_yield_ownership_unique_id_none(console_widget_with_static_id): unique_id = widget._unique_id widget._unique_id = None - result = _web_console_registry.yield_ownership(gui_id) + result = _bec_console_registry.yield_ownership(gui_id) assert result is False widget._unique_id = unique_id # Restore for cleanup @@ -408,7 +408,7 @@ def test_registry_yield_ownership_unique_id_not_in_page_registry(console_widget_ unique_id = widget._unique_id widget._unique_id = "nonexistent_unique_id" - result = _web_console_registry.yield_ownership(gui_id) + result = _bec_console_registry.yield_ownership(gui_id) assert result is False widget._unique_id = unique_id # Restore for cleanup @@ -416,7 +416,7 @@ def test_registry_yield_ownership_unique_id_not_in_page_registry(console_widget_ def test_registry_owner_is_visible_page_info_none(): """Test owner_is_visible returns False when page info doesn't exist.""" - result = _web_console_registry.owner_is_visible("nonexistent_page") + result = _bec_console_registry.owner_is_visible("nonexistent_page") assert result is False @@ -426,10 +426,10 @@ def test_registry_owner_is_visible_no_owner(console_widget_with_static_id): # Yield ownership so there's no owner widget.yield_ownership() - page_info = _web_console_registry.get_page_info(widget._unique_id) + page_info = _bec_console_registry.get_page_info(widget._unique_id) assert page_info.owner_gui_id is None - result = _web_console_registry.owner_is_visible(widget._unique_id) + result = _bec_console_registry.owner_is_visible(widget._unique_id) assert result is False @@ -439,9 +439,9 @@ def test_registry_owner_is_visible_owner_ref_none(console_widget_with_static_id) unique_id = widget._unique_id # Remove owner from instances dict - del _web_console_registry._instances[widget.gui_id] + del _bec_console_registry._consoles[widget.gui_id] - result = _web_console_registry.owner_is_visible(unique_id) + result = _bec_console_registry.owner_is_visible(unique_id) assert result is False @@ -452,9 +452,9 @@ def test_registry_owner_is_visible_owner_instance_none(console_widget_with_stati gui_id = widget.gui_id # Simulate dead weakref - _web_console_registry._instances[gui_id] = lambda: None + _bec_console_registry._consoles[gui_id] = lambda: None - result = _web_console_registry.owner_is_visible(unique_id) + result = _bec_console_registry.owner_is_visible(unique_id) assert result is False @@ -463,7 +463,7 @@ def test_registry_owner_is_visible_owner_visible(console_widget_with_static_id): widget = console_widget_with_static_id widget.show() - result = _web_console_registry.owner_is_visible(widget._unique_id) + result = _bec_console_registry.owner_is_visible(widget._unique_id) assert result is True @@ -472,5 +472,5 @@ def test_registry_owner_is_visible_owner_not_visible(console_widget_with_static_ widget = console_widget_with_static_id widget.hide() - result = _web_console_registry.owner_is_visible(widget._unique_id) + result = _bec_console_registry.owner_is_visible(widget._unique_id) assert result is False