1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-10 10:40:55 +02:00

Compare commits

..

2 Commits

64 changed files with 421 additions and 1760 deletions

View File

@@ -1,22 +1,6 @@
# CHANGELOG
## v2.43.0 (2025-10-30)
### Features
- Add pdf viewer widget
([`13a9175`](https://github.com/bec-project/bec_widgets/commit/13a9175ba5f5e1e2404d7302404d9511872aafc7))
## v2.42.1 (2025-10-28)
### Bug Fixes
- **rpc_server**: Raise window, even if minimized
([`6bc1c3c`](https://github.com/bec-project/bec_widgets/commit/6bc1c3c5f1b3e57ab8e8aeabcc1c0a52a56bbf0a))
## v2.42.0 (2025-10-21)
### Features

View File

@@ -45,7 +45,6 @@ _Widgets = {
"MonacoWidget": "MonacoWidget",
"MotorMap": "MotorMap",
"MultiWaveform": "MultiWaveform",
"PdfViewerWidget": "PdfViewerWidget",
"PositionIndicator": "PositionIndicator",
"PositionerBox": "PositionerBox",
"PositionerBox2D": "PositionerBox2D",
@@ -3422,137 +3421,6 @@ class MultiWaveform(RPCBase):
"""
class PdfViewerWidget(RPCBase):
"""A widget to display PDF documents with toolbar controls."""
@rpc_call
def load_pdf(self, file_path: str):
"""
Load a PDF file into the viewer.
Args:
file_path (str): Path to the PDF file to load.
"""
@rpc_call
def zoom_in(self):
"""
Zoom in the PDF view.
"""
@rpc_call
def zoom_out(self):
"""
Zoom out the PDF view.
"""
@rpc_call
def fit_to_width(self):
"""
Fit PDF to width.
"""
@rpc_call
def fit_to_page(self):
"""
Fit PDF to page.
"""
@rpc_call
def reset_zoom(self):
"""
Reset zoom to 100% (1.0 factor).
"""
@rpc_call
def previous_page(self):
"""
Go to previous page.
"""
@rpc_call
def next_page(self):
"""
Go to next page.
"""
@rpc_call
def toggle_continuous_scroll(self, checked: bool):
"""
Toggle between single page and continuous scroll mode.
Args:
checked (bool): True to enable continuous scroll, False for single page mode.
"""
@property
@rpc_call
def page_spacing(self):
"""
Get the spacing between pages in continuous scroll mode.
"""
@page_spacing.setter
@rpc_call
def page_spacing(self):
"""
Get the spacing between pages in continuous scroll mode.
"""
@property
@rpc_call
def side_margins(self):
"""
Get the horizontal margins (side spacing) around the PDF content.
"""
@side_margins.setter
@rpc_call
def side_margins(self):
"""
Get the horizontal margins (side spacing) around the PDF content.
"""
@rpc_call
def go_to_first_page(self):
"""
Go to the first page.
"""
@rpc_call
def go_to_last_page(self):
"""
Go to the last page.
"""
@rpc_call
def jump_to_page(self, page_number: int):
"""
Jump to a specific page number (1-based index).
"""
@property
@rpc_call
def current_page(self):
"""
Get the current page number (1-based index).
"""
@property
@rpc_call
def current_file_path(self):
"""
Get the current PDF file path.
"""
@current_file_path.setter
@rpc_call
def current_file_path(self):
"""
Get the current PDF file path.
"""
class PositionIndicator(RPCBase):
"""Display a position within a defined range, e.g. motor limits."""

View File

@@ -9,7 +9,7 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.redis_connector import RedisConnector
from bec_lib.scan_history import ScanHistory
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
from bec_widgets.tests.fake_devices import DEVICES, DMMock, FakePositioner, Positioner
def fake_redis_server(host, port):

View File

@@ -0,0 +1,285 @@
from unittest.mock import MagicMock
from bec_lib.device import Device as BECDevice
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority
from bec_lib.devicemanager import DeviceContainer
class FakeDevice(BECDevice):
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
super().__init__(name=name)
self._enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._readout_priority = readout_priority
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": {"user device"},
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.signals[self.name]["value"] = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
class FakePositioner(BECPositioner):
def __init__(
self,
name,
enabled=True,
limits=None,
read_value=1.0,
readout_priority=ReadoutPriority.MONITORED,
):
super().__init__(name=name)
# self.limits = limits if limits is not None else [0.0, 0.0]
self.read_value = read_value
self.setpoint_value = read_value
self.motor_is_moving_value = 0
self._enabled = enabled
self._limits = limits
self._readout_priority = readout_priority
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": {"user motors"},
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
"readback": {
"kind_str": "hinted",
"component_name": "readback",
"obj_name": self.name,
}, # hinted
"setpoint": {
"kind_str": "normal",
"component_name": "setpoint",
"obj_name": f"{self.name}_setpoint",
}, # normal
"velocity": {
"kind_str": "config",
"component_name": "velocity",
"obj_name": f"{self.name}_velocity",
}, # config
}
}
self.signals = {
self.name: {"value": self.read_value},
f"{self.name}_setpoint": {"value": self.setpoint_value},
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.read_value = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
@property
def precision(self):
return 3
def set_read_value(self, value):
self.read_value = value
def read(self, cached=False):
return self.signals
def set_limits(self, limits):
self.limits = limits
def move(self, value, relative=False):
"""Simulates moving the device to a new position."""
if relative:
self.read_value += value
else:
self.read_value = value
# Respect the limits
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
@property
def readback(self):
return MagicMock(get=MagicMock(return_value=self.read_value))
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
class Device(FakeDevice):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name, enabled=True):
super().__init__(name, enabled)
class DMMock:
def __init__(self):
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devices(self, devices: list):
"""
Add devices to the DeviceContainer.
Args:
devices (list): List of device instances to add.
"""
for device in devices:
self.devices[device.name] = device
def get_bec_signals(self, signal_class_name: str):
"""
Emulate DeviceManager.get_bec_signals for unit-tests.
For “AsyncSignal” we list every device whose readout_priority is
ReadoutPriority.ASYNC and build a minimal tuple
(device_name, signal_name, signal_info_dict) that matches the real
API shape used by Waveform._check_async_signal_found.
"""
signals: list[tuple[str, str, dict]] = []
if signal_class_name != "AsyncSignal":
return signals
for device in self.devices.values():
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
device_name = device.name
signal_name = device.name # primary signal in our mocks
signal_info = {
"component_name": signal_name,
"obj_name": signal_name,
"kind_str": "hinted",
"signal_class": signal_class_name,
"metadata": {
"connected": True,
"precision": None,
"read_access": True,
"timestamp": 0.0,
"write_access": True,
},
}
signals.append((device_name, signal_name, signal_info))
return signals
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
FakePositioner("aptrx", limits=None, read_value=4.0),
FakePositioner("aptry", limits=None, read_value=5.0),
FakeDevice("gauss_bpm"),
FakeDevice("gauss_adc1"),
FakeDevice("gauss_adc2"),
FakeDevice("gauss_adc3"),
FakeDevice("bpm4i"),
FakeDevice("bpm3a"),
FakeDevice("bpm3i"),
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
FakeDevice("waveform1d"),
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"),
]
def check_remote_data_size(widget, plot_name, num_elements):
"""
Check if the remote data has the correct number of elements.
Used in the qtbot.waitUntil function.
"""
return len(widget.get_all_data()[plot_name]["x"]) == num_elements

View File

@@ -1,285 +1,76 @@
from unittest.mock import MagicMock
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication
from bec_lib.device import Device as BECDevice
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority
from bec_lib.devicemanager import DeviceContainer
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
from bec_widgets.utils import error_popups
class FakeDevice(BECDevice):
"""Fake minimal positioner class for testing."""
class TestableQTimer(QTimer):
_instances: list[tuple[QTimer, str]] = []
_current_test_name: str = ""
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
super().__init__(name=name)
self._enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._readout_priority = readout_priority
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": {"user device"},
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
TestableQTimer._instances.append((self, TestableQTimer._current_test_name))
@property
def readout_priority(self):
return self._readout_priority
@classmethod
def check_all_stopped(cls, qtbot):
def _is_done_or_deleted(t: QTimer):
try:
return not t.isActive()
except RuntimeError as e:
return "already deleted" in e.args[0]
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.signals[self.name]["value"] = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
try:
qtbot.waitUntil(lambda: all(_is_done_or_deleted(timer) for timer, _ in cls._instances))
except QtBotTimeoutError as exc:
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
(t.stop() for t, _ in cls._instances)
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc
cls._instances = []
class FakePositioner(BECPositioner):
def qapplication_fixture(qtbot, request, testable_qtimer_class):
yield
def __init__(
self,
name,
enabled=True,
limits=None,
read_value=1.0,
readout_priority=ReadoutPriority.MONITORED,
):
super().__init__(name=name)
# self.limits = limits if limits is not None else [0.0, 0.0]
self.read_value = read_value
self.setpoint_value = read_value
self.motor_is_moving_value = 0
self._enabled = enabled
self._limits = limits
self._readout_priority = readout_priority
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": {"user motors"},
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
"readback": {
"kind_str": "hinted",
"component_name": "readback",
"obj_name": self.name,
}, # hinted
"setpoint": {
"kind_str": "normal",
"component_name": "setpoint",
"obj_name": f"{self.name}_setpoint",
}, # normal
"velocity": {
"kind_str": "config",
"component_name": "velocity",
"obj_name": f"{self.name}_velocity",
}, # config
}
}
self.signals = {
self.name: {"value": self.read_value},
f"{self.name}_setpoint": {"value": self.setpoint_value},
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
}
if request.node.stash._storage.get("failed"):
print("Test failed, skipping cleanup checks")
return
@property
def readout_priority(self):
return self._readout_priority
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
bec_dispatcher.stop_cli_server()
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.read_value = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
@property
def precision(self):
return 3
def set_read_value(self, value):
self.read_value = value
def read(self, cached=False):
return self.signals
def set_limits(self, limits):
self.limits = limits
def move(self, value, relative=False):
"""Simulates moving the device to a new position."""
if relative:
self.read_value += value
else:
self.read_value = value
# Respect the limits
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
@property
def readback(self):
return MagicMock(get=MagicMock(return_value=self.read_value))
testable_qtimer_class.check_all_stopped(qtbot)
qapp = QApplication.instance()
qapp.processEvents()
if hasattr(qapp, "os_listener") and qapp.os_listener:
qapp.removeEventFilter(qapp.os_listener)
try:
qtbot.waitUntil(lambda: qapp.topLevelWidgets() == [])
except QtBotTimeoutError as exc:
raise TimeoutError(f"Failed to close all widgets: {qapp.topLevelWidgets()}") from exc
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
def rpc_register_fixture():
try:
yield RPCRegister()
finally:
RPCRegister.reset_singleton()
class Device(FakeDevice):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name, enabled=True):
super().__init__(name, enabled)
def bec_dispatcher_fixture(threads_check):
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
try:
yield bec_dispatcher
finally:
bec_dispatcher.disconnect_all()
bec_dispatcher.client.shutdown()
bec_dispatcher.stop_cli_server()
bec_dispatcher_module.BECDispatcher.reset_singleton()
class DMMock:
def __init__(self):
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devices(self, devices: list):
"""
Add devices to the DeviceContainer.
Args:
devices (list): List of device instances to add.
"""
for device in devices:
self.devices[device.name] = device
def get_bec_signals(self, signal_class_name: str):
"""
Emulate DeviceManager.get_bec_signals for unit-tests.
For “AsyncSignal” we list every device whose readout_priority is
ReadoutPriority.ASYNC and build a minimal tuple
(device_name, signal_name, signal_info_dict) that matches the real
API shape used by Waveform._check_async_signal_found.
"""
signals: list[tuple[str, str, dict]] = []
if signal_class_name != "AsyncSignal":
return signals
for device in self.devices.values():
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
device_name = device.name
signal_name = device.name # primary signal in our mocks
signal_info = {
"component_name": signal_name,
"obj_name": signal_name,
"kind_str": "hinted",
"signal_class": signal_class_name,
"metadata": {
"connected": True,
"precision": None,
"read_access": True,
"timestamp": 0.0,
"write_access": True,
},
}
signals.append((device_name, signal_name, signal_info))
return signals
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
FakePositioner("aptrx", limits=None, read_value=4.0),
FakePositioner("aptry", limits=None, read_value=5.0),
FakeDevice("gauss_bpm"),
FakeDevice("gauss_adc1"),
FakeDevice("gauss_adc2"),
FakeDevice("gauss_adc3"),
FakeDevice("bpm4i"),
FakeDevice("bpm3a"),
FakeDevice("bpm3i"),
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
FakeDevice("waveform1d"),
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"),
]
def check_remote_data_size(widget, plot_name, num_elements):
"""
Check if the remote data has the correct number of elements.
Used in the qtbot.waitUntil function.
"""
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
def clean_singleton_fixture():
error_popups._popup_utility_instance = None
yield

View File

@@ -11,7 +11,7 @@ from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication
from redis.exceptions import RedisError
@@ -129,44 +129,16 @@ class RPCServer:
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
if method == "raise" and hasattr(
obj, "setWindowState"
): # special case for raising windows, should work even if minimized
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
# The procedure is as follows:
# 1. Get the current window state to check if the window is minimized and remove minimized flag
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
# and call raise_() and activateWindow()
# This forces gnome to raise the window even if focus stealing is prevented
# 3. Flag for stay on top is removed again to restore the original window state
# 4. Finally, we call show() to ensure the window is visible
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
target_state = state | Qt.WindowActive
if state & Qt.WindowMinimized:
target_state &= ~Qt.WindowMinimized
obj.setWindowState(target_state)
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
obj.showNormal()
if hasattr(obj, "raise_"):
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
obj.raise_()
if hasattr(obj, "activateWindow"):
obj.activateWindow()
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
obj.show()
res = None
else:
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
res = method_obj(*args, **kwargs)
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]

View File

@@ -1,574 +0,0 @@
import os
from typing import Optional
from qtpy.QtCore import QMargins, Qt, Signal
from qtpy.QtGui import QIntValidator
from qtpy.QtPdf import QPdfDocument
from qtpy.QtPdfWidgets import QPdfView
from qtpy.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
class PdfViewerWidget(BECWidget, QWidget):
"""A widget to display PDF documents with toolbar controls."""
# Emitted when a PDF document is successfully loaded, providing the file path.
document_ready = Signal(str)
PLUGIN = True
RPC = True
ICON_NAME = "picture_as_pdf"
USER_ACCESS = [
"load_pdf",
"zoom_in",
"zoom_out",
"fit_to_width",
"fit_to_page",
"reset_zoom",
"previous_page",
"next_page",
"toggle_continuous_scroll",
"page_spacing",
"page_spacing.setter",
"side_margins",
"side_margins.setter",
"go_to_first_page",
"go_to_last_page",
"jump_to_page",
"current_page",
"current_file_path",
"current_file_path.setter",
]
def __init__(
self, parent: Optional[QWidget] = None, config=None, client=None, gui_id=None, **kwargs
):
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
# Set up the layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create the PDF document and view first
self._pdf_document = QPdfDocument(self)
self.pdf_view = QPdfView()
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
# Create toolbar after PDF components are initialized
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self._setup_toolbar()
# Add widgets to layout
layout.addWidget(self.toolbar)
layout.addWidget(self.pdf_view)
# Current file path and spacing settings
self._current_file_path = None
self._page_spacing = 5 # Default spacing between pages in continuous mode
self._side_margins = 10 # Default side margins (horizontal spacing)
def _setup_toolbar(self):
"""Set up the toolbar with PDF control buttons."""
# Create separate bundles for different control groups
file_bundle = self.toolbar.new_bundle("file_controls")
zoom_bundle = self.toolbar.new_bundle("zoom_controls")
view_bundle = self.toolbar.new_bundle("view_controls")
nav_bundle = self.toolbar.new_bundle("navigation_controls")
# File operations
open_action = MaterialIconAction(
icon_name="folder_open", tooltip="Open PDF File", parent=self
)
open_action.action.triggered.connect(self.open_file_dialog)
self.toolbar.components.add("open_file", open_action)
file_bundle.add_action("open_file")
# Zoom controls
zoom_in_action = MaterialIconAction(icon_name="zoom_in", tooltip="Zoom In", parent=self)
zoom_in_action.action.triggered.connect(self.zoom_in)
self.toolbar.components.add("zoom_in", zoom_in_action)
zoom_bundle.add_action("zoom_in")
zoom_out_action = MaterialIconAction(icon_name="zoom_out", tooltip="Zoom Out", parent=self)
zoom_out_action.action.triggered.connect(self.zoom_out)
self.toolbar.components.add("zoom_out", zoom_out_action)
zoom_bundle.add_action("zoom_out")
fit_width_action = MaterialIconAction(
icon_name="fit_screen", tooltip="Fit to Width", parent=self
)
fit_width_action.action.triggered.connect(self.fit_to_width)
self.toolbar.components.add("fit_width", fit_width_action)
zoom_bundle.add_action("fit_width")
fit_page_action = MaterialIconAction(
icon_name="fullscreen", tooltip="Fit to Page", parent=self
)
fit_page_action.action.triggered.connect(self.fit_to_page)
self.toolbar.components.add("fit_page", fit_page_action)
zoom_bundle.add_action("fit_page")
reset_zoom_action = MaterialIconAction(
icon_name="center_focus_strong", tooltip="Reset Zoom to 100%", parent=self
)
reset_zoom_action.action.triggered.connect(self.reset_zoom)
self.toolbar.components.add("reset_zoom", reset_zoom_action)
zoom_bundle.add_action("reset_zoom")
# View controls
continuous_scroll_action = MaterialIconAction(
icon_name="view_agenda", tooltip="Toggle Continuous Scroll", checkable=True, parent=self
)
continuous_scroll_action.action.toggled.connect(self.toggle_continuous_scroll)
self.toolbar.components.add("continuous_scroll", continuous_scroll_action)
view_bundle.add_action("continuous_scroll")
# Navigation controls
prev_page_action = MaterialIconAction(
icon_name="navigate_before", tooltip="Previous Page", parent=self
)
prev_page_action.action.triggered.connect(self.previous_page)
self.toolbar.components.add("prev_page", prev_page_action)
nav_bundle.add_action("prev_page")
next_page_action = MaterialIconAction(
icon_name="navigate_next", tooltip="Next Page", parent=self
)
next_page_action.action.triggered.connect(self.next_page)
self.toolbar.components.add("next_page", next_page_action)
nav_bundle.add_action("next_page")
# Page jump widget (in navigation bundle)
self._setup_page_jump_widget(nav_bundle)
# Show all bundles
self.toolbar.show_bundles(
["file_controls", "zoom_controls", "view_controls", "navigation_controls"]
)
# Initialize navigation button tooltips for single page mode (default)
self._update_navigation_buttons_for_mode(continuous=False)
# Initialize navigation button states
self._update_navigation_button_states()
def _setup_page_jump_widget(self, nav_bundle):
"""Set up the page jump widget (label + line edit)."""
# Create a container widget for the page jump controls
page_jump_container = QWidget()
page_jump_layout = QHBoxLayout(page_jump_container)
page_jump_layout.setContentsMargins(5, 0, 5, 0)
page_jump_layout.setSpacing(3)
# Page input field
self.page_input = QLineEdit()
self.page_input.setValidator(QIntValidator(1, 100000)) # restrict to 1100000
self.page_input.setFixedWidth(50)
self.page_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.page_input.setPlaceholderText("1")
self.page_input.setToolTip("Enter page number and press Enter")
self.page_input.returnPressed.connect(self._line_edit_jump_to_page)
# Total pages label
self.total_pages_label = QLabel("/ 1")
self.total_pages_label.setStyleSheet("color: #666; font-size: 12px;")
# Add widgets to layout
page_jump_layout.addWidget(self.page_input)
page_jump_layout.addWidget(self.total_pages_label)
# Create a WidgetAction for the page jump controls
# No manual separator needed - bundles are automatically separated
page_jump_action = WidgetAction(
label="Page:", widget=page_jump_container, adjust_size=False, parent=self
)
self.toolbar.components.add("page_jump", page_jump_action)
nav_bundle.add_action("page_jump")
def _line_edit_jump_to_page(self):
"""Jump to the page entered in the line edit."""
page_text = self.page_input.text().strip()
if not page_text:
return
# We validated input to be integer, so safe to convert directly
self.jump_to_page(int(page_text))
def _update_navigation_button_states(self):
"""Update the enabled/disabled state of navigation buttons."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
# No document loaded - disable all navigation
self._set_navigation_enabled(False, False)
self._update_page_display(1, 1)
return
navigator = self.pdf_view.pageNavigator()
current_page = navigator.currentPage()
total_pages = self._pdf_document.pageCount()
# Update button states
prev_enabled = current_page > 0
next_enabled = current_page < (total_pages - 1)
self._set_navigation_enabled(prev_enabled, next_enabled)
# Update page display
self._update_page_display(current_page + 1, total_pages)
def _set_navigation_enabled(self, prev_enabled: bool, next_enabled: bool):
"""Set the enabled state of navigation buttons."""
prev_action = self.toolbar.components.get_action("prev_page")
if prev_action and hasattr(prev_action, "action") and prev_action.action:
prev_action.action.setEnabled(prev_enabled)
next_action = self.toolbar.components.get_action("next_page")
if next_action and hasattr(next_action, "action") and next_action.action:
next_action.action.setEnabled(next_enabled)
def _update_page_display(self, current_page: int, total_pages: int):
"""Update the page display in the toolbar."""
if hasattr(self, "page_input"):
self.page_input.setText(str(current_page))
self.page_input.setPlaceholderText(str(current_page))
if hasattr(self, "total_pages_label"):
self.total_pages_label.setText(f"/ {total_pages}")
@SafeProperty(str)
def current_file_path(self):
"""Get the current PDF file path."""
return self._current_file_path
@current_file_path.setter
def current_file_path(self, value: str):
"""
Set the current PDF file path and load the document.
Args:
value (str): Path to the PDF file to load.
"""
if not isinstance(value, str):
raise ValueError("current_file_path must be a string")
self.load_pdf(value)
@SafeProperty(int)
def page_spacing(self):
"""Get the spacing between pages in continuous scroll mode."""
return self._page_spacing
@property
def current_page(self):
"""Get the current page number (1-based index)."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return 0
navigator = self.pdf_view.pageNavigator()
return navigator.currentPage() + 1
@page_spacing.setter
def page_spacing(self, value: int):
"""
Set the spacing between pages in continuous scroll mode.
Args:
value (int): Spacing in pixels (non-negative integer).
"""
if not isinstance(value, int):
raise ValueError("page_spacing must be an integer")
if value < 0:
raise ValueError("page_spacing must be non-negative")
self._page_spacing = value
# If currently in continuous scroll mode, update the spacing immediately
if self.pdf_view.pageMode() == QPdfView.PageMode.MultiPage:
self.pdf_view.setPageSpacing(self._page_spacing)
@SafeProperty(int)
def side_margins(self):
"""Get the horizontal margins (side spacing) around the PDF content."""
return self._side_margins
@side_margins.setter
def side_margins(self, value: int):
"""Set the horizontal margins (side spacing) around the PDF content."""
if not isinstance(value, int):
raise ValueError("side_margins must be an integer")
if value < 0:
raise ValueError("side_margins must be non-negative")
self._side_margins = value
# Update the document margins immediately
# setDocumentMargins takes a QMargins(left, top, right, bottom)
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
self.pdf_view.setDocumentMargins(margins)
def open_file_dialog(self):
"""Open a file dialog to select a PDF file."""
file_path, _ = QFileDialog.getOpenFileName(
self, "Open PDF File", "", "PDF Files (*.pdf);;All Files (*)"
)
if file_path:
self.load_pdf(file_path)
@SafeSlot(str, popup_error=True)
def load_pdf(self, file_path: str):
"""
Load a PDF file into the viewer.
Args:
file_path (str): Path to the PDF file to load.
"""
# Validate file exists
if not os.path.isfile(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
self._current_file_path = file_path
# Disconnect any existing signal connections
try:
self._pdf_document.statusChanged.disconnect(self._on_document_status_changed)
except (TypeError, RuntimeError):
pass
# Connect to statusChanged signal to handle when document is ready
self._pdf_document.statusChanged.connect(self._on_document_status_changed)
# Load the document
self._pdf_document.load(file_path)
# If already ready (synchronous loading), set document immediately
if self._pdf_document.status() == QPdfDocument.Status.Ready:
self._on_document_ready()
@SafeSlot(QPdfDocument.Status)
def _on_document_status_changed(self, status: QPdfDocument.Status):
"""Handle document status changes."""
status = self._pdf_document.status()
if status == QPdfDocument.Status.Ready:
self._on_document_ready()
elif status == QPdfDocument.Status.Error:
raise RuntimeError(f"Failed to load PDF document: {self._current_file_path}")
def _on_document_ready(self):
"""Handle when document is ready to be displayed."""
self.pdf_view.setDocument(self._pdf_document)
# Set initial margins
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
self.pdf_view.setDocumentMargins(margins)
# Connect to page changes to update navigation button states
navigator = self.pdf_view.pageNavigator()
navigator.currentPageChanged.connect(self._on_page_changed)
# Make sure we start at the first page
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
# Update initial navigation state
self._update_navigation_button_states()
self.document_ready.emit(self._current_file_path)
def _on_page_changed(self, _page):
"""Handle page change events to update navigation states."""
self._update_navigation_button_states()
# Toolbar action methods
@SafeSlot()
def zoom_in(self):
"""Zoom in the PDF view."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
current_factor = self.pdf_view.zoomFactor()
new_factor = current_factor * 1.25
self.pdf_view.setZoomFactor(new_factor)
@SafeSlot()
def zoom_out(self):
"""Zoom out the PDF view."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
current_factor = self.pdf_view.zoomFactor()
new_factor = max(current_factor / 1.25, 0.1)
self.pdf_view.setZoomFactor(new_factor)
@SafeSlot()
def fit_to_width(self):
"""Fit PDF to width."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
@SafeSlot()
def fit_to_page(self):
"""Fit PDF to page."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitInView)
@SafeSlot()
def reset_zoom(self):
"""Reset zoom to 100% (1.0 factor)."""
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
self.pdf_view.setZoomFactor(1.0)
@SafeSlot()
def previous_page(self):
"""Go to previous page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
current_page = navigator.currentPage()
if current_page == 0:
self._update_navigation_button_states()
return
try:
target_page = current_page - 1
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
except Exception:
try:
# Fallback: Use scroll to approximate position
page_height = self.pdf_view.viewport().height()
self.pdf_view.verticalScrollBar().setValue(
self.pdf_view.verticalScrollBar().value() - page_height
)
except Exception:
pass
# Update navigation button states (in case signal doesn't fire)
self._update_navigation_button_states()
@SafeSlot()
def next_page(self):
"""Go to next page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
current_page = navigator.currentPage()
max_page = self._pdf_document.pageCount() - 1
if current_page < max_page:
try:
target_page = current_page + 1
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
except Exception:
try:
# Fallback: Use scroll to approximate position
page_height = self.pdf_view.viewport().height()
self.pdf_view.verticalScrollBar().setValue(
self.pdf_view.verticalScrollBar().value() + page_height
)
except Exception:
pass
# Update navigation button states (in case signal doesn't fire)
self._update_navigation_button_states()
@SafeSlot(bool)
def toggle_continuous_scroll(self, checked: bool):
"""
Toggle between single page and continuous scroll mode.
Args:
checked (bool): True to enable continuous scroll, False for single page mode.
"""
if checked:
self.pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
self.pdf_view.setPageSpacing(self._page_spacing)
self._update_navigation_buttons_for_mode(continuous=True)
tooltip = "Switch to Single Page Mode"
else:
self.pdf_view.setPageMode(QPdfView.PageMode.SinglePage)
self._update_navigation_buttons_for_mode(continuous=False)
tooltip = "Switch to Continuous Scroll Mode"
# Update navigation button states after mode change
self._update_navigation_button_states()
# Update toggle button tooltip to reflect current state
action = self.toolbar.components.get_action("continuous_scroll")
if action and hasattr(action, "action") and action.action:
action.action.setToolTip(tooltip)
def _update_navigation_buttons_for_mode(self, continuous: bool):
"""Update navigation button tooltips based on current mode."""
prev_action = self.toolbar.components.get_action("prev_page")
next_action = self.toolbar.components.get_action("next_page")
if continuous:
prev_actions_tooltip = "Previous Page (use scroll in continuous mode)"
next_actions_tooltip = "Next Page (use scroll in continuous mode)"
else:
prev_actions_tooltip = "Previous Page"
next_actions_tooltip = "Next Page"
if prev_action and hasattr(prev_action, "action") and prev_action.action:
prev_action.action.setToolTip(prev_actions_tooltip)
if next_action and hasattr(next_action, "action") and next_action.action:
next_action.action.setToolTip(next_actions_tooltip)
@SafeSlot()
def go_to_first_page(self):
"""Go to the first page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
@SafeSlot()
def go_to_last_page(self):
"""Go to the last page."""
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
return
navigator = self.pdf_view.pageNavigator()
last_page = self._pdf_document.pageCount() - 1
navigator.update(last_page, navigator.currentLocation(), navigator.currentZoom())
@SafeSlot(int)
def jump_to_page(self, page_number: int):
"""Jump to a specific page number (1-based index)."""
if not isinstance(page_number, int):
raise ValueError("page_number must be an integer")
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
raise RuntimeError("No PDF document loaded")
max_page = self._pdf_document.pageCount()
page_number = max(min(page_number, max_page), 1)
target_page = page_number - 1 # Convert to 0-based index
navigator = self.pdf_view.pageNavigator()
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
def cleanup(self):
"""Handle widget close event to prevent segfaults."""
if hasattr(self, "_pdf_document") and self._pdf_document:
self._pdf_document.statusChanged.disconnect()
empty_doc = QPdfDocument(self)
self.pdf_view.setDocument(empty_doc)
if hasattr(self, "toolbar"):
self.toolbar.cleanup()
super().cleanup()
if __name__ == "__main__":
import sys
# from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
# apply_theme("dark")
viewer = PdfViewerWidget()
# viewer.load_pdf("/Path/To/Your/TestDocument.pdf")
viewer.next_page()
# viewer.page_spacing = 0
# viewer.side_margins = 0
viewer.resize(1000, 700)
viewer.show()
sys.exit(app.exec())

View File

@@ -1 +0,0 @@
{'files': ['pdf_viewer.py']}

View File

@@ -1,57 +0,0 @@
# 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 bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
DOM_XML = """
<ui language='c++'>
<widget class='PdfViewerWidget' name='pdf_viewer_widget'>
</widget>
</ui>
"""
class PdfViewerWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PdfViewerWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(PdfViewerWidget.ICON_NAME)
def includeFile(self):
return "pdf_viewer_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 "PdfViewerWidget"
def toolTip(self):
return "A widget to display PDF documents with toolbar controls."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,17 +0,0 @@
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 bec_widgets.widgets.utility.pdf_viewer.pdf_viewer_widget_plugin import (
PdfViewerWidgetPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(PdfViewerWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

View File

@@ -1,119 +0,0 @@
(user.widgets.pdf_viewer_widget)=
# PDF Viewer Widget
````{tab} Overview
The PDF Viewer Widget is a versatile tool designed for displaying and navigating PDF documents within your BEC applications. Directly integrated with the `BEC` framework, it provides a full-featured PDF viewing experience with zoom controls, page navigation, and customizable display options.
## Key Features:
- **Flexible Integration**: The widget can be integrated into [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`.
- **Full PDF Support**: Display any PDF document with full rendering support through Qt's PDF rendering engine.
- **Navigation Controls**: Built-in toolbar with page navigation, zoom controls, and document status indicators.
- **Customizable Display**: Adjustable page spacing, margins, and zoom levels for optimal viewing experience.
- **Document Management**: Load different PDF files dynamically during runtime with proper error handling.
## User Interface Components:
- **Toolbar**: Contains all navigation and zoom controls
- Previous/Next page buttons
- Page number input field with total page count
- First/Last page navigation buttons
- Zoom in/out buttons
- Fit to width/page buttons
- Reset zoom button
- **PDF View Area**: Main display area for the PDF content
````
````{tab} Examples - CLI
`PdfViewerWidget` can be embedded in [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`. The command-line API is the same for all cases.
## Example 1 - Basic PDF Loading
In this example, we demonstrate how to add a `PdfViewerWidget` to a [`BECDockArea`](user.widgets.bec_dock_area) and load a PDF document.
```python
# Add a new dock with PDF viewer widget
dock_area = gui.new()
pdf_viewer = dock_area.new().new(gui.available_widgets.PdfViewerWidget)
# Load a PDF file
pdf_viewer.load_pdf("/path/to/your/document.pdf")
```
## Example 2 - Customizing Display Properties
This example shows how to customize the display properties of the PDF viewer for better presentation.
```python
# Create PDF viewer
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
# Load PDF document
pdf_viewer.load_pdf("/path/to/report.pdf")
pdf_viewer.toggle_continuous_scroll(True) # Enable continuous scroll mode
# Customize display properties
pdf_viewer.page_spacing = 20 # Increase spacing between pages
pdf_viewer.side_margins = 50 # Add horizontal margins
# Navigate to specific page
pdf_viewer.jump_to_page(5) # Go to page 5
```
## Example 3 - Navigation and Zoom Controls
The PDF viewer provides programmatic access to all navigation and zoom functionality.
```python
# Create and load PDF
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
pdf_viewer.load_pdf("/path/to/manual.pdf")
# Navigation examples
pdf_viewer.go_to_first_page() # Go to first page
pdf_viewer.go_to_last_page() # Go to last page
pdf_viewer.jump_to_page(10) # Jump to specific page
# Zoom controls
pdf_viewer.zoom_in() # Increase zoom
pdf_viewer.zoom_out() # Decrease zoom
pdf_viewer.fit_to_width() # Fit document to window width
pdf_viewer.fit_to_page() # Fit entire page to window
pdf_viewer.reset_zoom() # Reset to 100% zoom
# Check current status
current_page = pdf_viewer.current_page
print(f"Currently viewing page {current_page}")
```
## Example 4 - Dynamic Document Loading
This example demonstrates how to switch between different PDF documents dynamically.
```python
# Create PDF viewer
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
# Load first document
pdf_viewer.load_pdf("/path/to/document1.pdf")
# Or simply set the current file path
pdf_viewer.current_file_path = "/path/to/document2.pdf"
# This automatically loads the new document
# Check which file is currently loaded
current_file = pdf_viewer.current_file_path
print(f"Currently viewing: {current_file}")
```
````
````{tab} API
```{eval-rst}
.. autoclass:: bec_widgets.cli.client.PdfViewerWidget
:members:
:show-inheritance:
```
````

View File

@@ -270,14 +270,6 @@ Select DAP model from a list of DAP processes.
Show and filter logs from the BEC Redis server.
```
```{grid-item-card} PDF Viewer Widget
:link: user.widgets.pdf_viewer_widget
:link-type: ref
:img-top: /assets/widget_screenshots/pdf_viewer.png
Display and navigate PDF documents.
```
````
```{toctree}
@@ -315,7 +307,6 @@ dap_combo_box/dap_combo_box.md
games/games.md
log_panel/log_panel.md
signal_label/signal_label.md
pdf_viewer/pdf_viewer_widget.md
```

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.43.0"
version = "2.42.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [

View File

@@ -1,33 +1,7 @@
import pytest
import qtpy.QtCore
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QTimer
class TestableQTimer(QTimer):
_instances: list[tuple[QTimer, str]] = []
_current_test_name: str = ""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
TestableQTimer._instances.append((self, TestableQTimer._current_test_name))
@classmethod
def check_all_stopped(cls, qtbot):
def _is_done_or_deleted(t: QTimer):
try:
return not t.isActive()
except RuntimeError as e:
return "already deleted" in e.args[0]
try:
qtbot.waitUntil(lambda: all(_is_done_or_deleted(timer) for timer, _ in cls._instances))
except QtBotTimeoutError as exc:
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
(t.stop() for t, _ in cls._instances)
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc
cls._instances = []
from bec_widgets.tests.utils import TestableQTimer
# To support 'from qtpy.QtCore import QTimer' syntax we just replace this completely for the test session
# see: https://docs.python.org/3/library/unittest.mock.html#where-to-patch

View File

@@ -6,7 +6,7 @@ from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client import Image, MotorMap, MultiWaveform, ScatterWaveform, Waveform
from bec_widgets.cli.rpc.rpc_base import RPCReference
from bec_widgets.tests.utils import check_remote_data_size
from bec_widgets.tests.fake_devices import check_remote_data_size
def test_rpc_waveform1d_custom_curve(qtbot, connected_client_gui_obj):

View File

@@ -6,12 +6,9 @@ import numpy as np
import pytest
from bec_lib import messages
from bec_lib.messages import _StoredDataInfo
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtWidgets import QApplication, QMessageBox
from qtpy.QtWidgets import QMessageBox
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
from bec_widgets.utils import error_popups
from bec_widgets.tests import utils as test_utils
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
@@ -25,49 +22,22 @@ def pytest_runtest_makereport(item, call):
@pytest.fixture(autouse=True)
def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument
yield
# if the test failed, we don't want to check for open widgets as
# it simply pollutes the output
if request.node.stash._storage.get("failed"):
print("Test failed, skipping cleanup checks")
return
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
bec_dispatcher.stop_cli_server()
testable_qtimer_class.check_all_stopped(qtbot)
qapp = QApplication.instance()
qapp.processEvents()
if hasattr(qapp, "os_listener") and qapp.os_listener:
qapp.removeEventFilter(qapp.os_listener)
try:
qtbot.waitUntil(lambda: qapp.topLevelWidgets() == [])
except QtBotTimeoutError as exc:
raise TimeoutError(f"Failed to close all widgets: {qapp.topLevelWidgets()}") from exc
yield from test_utils.qapplication_fixture(qtbot, request, testable_qtimer_class)
@pytest.fixture(autouse=True)
def rpc_register():
yield RPCRegister()
RPCRegister.reset_singleton()
yield from test_utils.rpc_register_fixture()
@pytest.fixture(autouse=True)
def bec_dispatcher(threads_check): # pylint: disable=unused-argument
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
yield bec_dispatcher
bec_dispatcher.disconnect_all()
# clean BEC client
bec_dispatcher.client.shutdown()
# stop the cli server
bec_dispatcher.stop_cli_server()
# reinitialize singleton for next test
bec_dispatcher_module.BECDispatcher.reset_singleton()
yield from test_utils.bec_dispatcher_fixture(threads_check)
@pytest.fixture(autouse=True)
def clean_singleton():
error_popups._popup_utility_instance = None
yield from test_utils.clean_singleton_fixture()
@pytest.fixture(autouse=True)

View File

@@ -2,10 +2,9 @@
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
from .client_mocks import mocked_client
@pytest.fixture
def abort_button(qtbot, mocked_client):

View File

@@ -1,9 +1,9 @@
import pytest
from qtpy.QtWidgets import QDoubleSpinBox, QLineEdit
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget

View File

@@ -5,11 +5,10 @@ import pytest
from qtpy.QtCore import QObject
from qtpy.QtWidgets import QApplication
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils import BECConnector
from bec_widgets.utils.error_popups import SafeSlot as Slot
from .client_mocks import mocked_client
class BECConnectorQObject(BECConnector, QObject): ...

View File

@@ -5,9 +5,9 @@ from unittest import mock
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.containers.dock import BECDockArea
from .client_mocks import mocked_client
from .test_bec_queue import bec_queue_msg_full

View File

@@ -1,10 +1,9 @@
import pytest
from bec_lib import messages
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from .client_mocks import mocked_client
@pytest.fixture
def bec_queue_msg_full():

View File

@@ -4,13 +4,12 @@ from unittest import mock
import pytest
from bec_lib.messages import BECStatus, ServiceMetricMessage, StatusMessage
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.services.bec_status_box.bec_status_box import (
BECServiceInfoContainer,
BECStatusBox,
)
from .client_mocks import mocked_client
@pytest.fixture
def service_status_fixture():

View File

@@ -4,11 +4,11 @@ from pydantic import ValidationError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.plots.waveform.curve import CurveConfig
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget

View File

@@ -4,10 +4,10 @@ import pytest
from qtpy.QtCore import QPointF, Qt
from qtpy.QtGui import QTransform
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils import Crosshair
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -6,13 +6,13 @@ from bec_lib.scan_history import ScanHistory
from qtpy.QtGui import QValidator
from qtpy.QtWidgets import QComboBox, QVBoxLayout
from bec_widgets.tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import (
CurveTree,
ScanIndexValidator,
)
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
from tests.unit_tests.conftest import create_widget
##################################################

View File

@@ -1,8 +1,8 @@
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from .client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -4,12 +4,11 @@ import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
# pylint: disable=unused-import
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
# pylint: disable=unused-import
from .client_mocks import mocked_client
# pylint: disable=redefined-outer-name

View File

@@ -6,6 +6,7 @@ from bec_lib.device import Device
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QTabWidget
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
@@ -14,8 +15,6 @@ from bec_widgets.widgets.services.device_browser.device_item.device_signal_displ
SignalDisplay,
)
from .client_mocks import mocked_client
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QListWidgetItem

View File

@@ -4,13 +4,13 @@ import pytest
from bec_lib.device import ReadoutPriority
from qtpy.QtWidgets import QWidget
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
DeviceInputConfig,
)
from .client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -1,14 +1,13 @@
import pytest
from bec_lib.device import ReadoutPriority
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from .client_mocks import mocked_client
@pytest.fixture
def device_input_combobox(qtbot, mocked_client):

View File

@@ -4,7 +4,7 @@ import pytest
from bec_lib.device import Signal
from qtpy.QtWidgets import QWidget
from bec_widgets.tests.utils import FakeDevice
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
@@ -16,7 +16,6 @@ from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit
SignalLineEdit,
)
from .client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -1,12 +1,12 @@
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from .client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -5,12 +5,9 @@ import pytest
from bec_lib import messages
from bec_lib.scan_history import ScanHistory
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal
# pytest: disable=unused-import
from tests.unit_tests.client_mocks import mocked_client
from .client_mocks import create_dummy_scan_item
from bec_widgets.tests.client_mocks import create_dummy_scan_item, mocked_client
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal
@pytest.fixture

View File

@@ -4,10 +4,10 @@ import numpy as np
import pytest
from qtpy.QtCore import QPointF, Qt
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget

View File

@@ -5,6 +5,7 @@ from typing import Literal
import numpy as np
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.roi.image_roi import (
CircularROI,
@@ -12,7 +13,6 @@ from bec_widgets.widgets.plots.roi.image_roi import (
RectangularROI,
ROIController,
)
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget

View File

@@ -3,8 +3,8 @@ import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.plots.image.image import Image
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
##################################################

View File

@@ -8,11 +8,10 @@ from qtpy.QtGui import QFontMetrics
import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from .client_mocks import mocked_client
base_path = os.path.dirname(bec_widgets.__file__)

View File

@@ -3,9 +3,9 @@ from unittest import mock
import numpy as np
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from .client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -8,18 +8,12 @@ from unittest.mock import MagicMock, patch
import pytest
from bec_lib.messages import LogMessage
from bec_lib.redis_connector import StreamMessage
from qtpy.QtCore import QDateTime
from bec_widgets.widgets.utility.logpanel._util import (
log_time,
replace_escapes,
simple_color_format,
)
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.utility.logpanel._util import replace_escapes, simple_color_format
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
from .client_mocks import mocked_client
TEST_TABLE_STRING = "2025-01-15 15:57:18 | bec_server.scan_server.scan_queue | [INFO] | \n \x1b[3m primary queue / ScanQueueStatus.RUNNING \x1b[0m\n┏━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓\n\x1b[1m \x1b[0m\x1b[1m queue_id \x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_id\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mis_scan\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mtype\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_numb…\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mIQ status\x1b[0m\x1b[1m \x1b[0m┃\n┡━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩\n│ bbe50c82-6… │ None │ False │ mv │ None │ PENDING │\n└─────────────┴─────────┴─────────┴──────┴────────────┴───────────┘\n\n"
TEST_LOG_MESSAGES = [

View File

@@ -5,6 +5,7 @@ from qtpy.QtCore import QEvent, QPoint, QPointF
from qtpy.QtGui import QEnterEvent
from qtpy.QtWidgets import QApplication, QFrame, QLabel
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.containers.main_window.addons.hover_widget import (
HoverWidget,
WidgetTooltip,
@@ -13,7 +14,6 @@ from bec_widgets.widgets.containers.main_window.addons.scroll_label import Scrol
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from .client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -1,8 +1,8 @@
import numpy as np
import pyqtgraph as pg
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from tests.unit_tests.client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -1,7 +1,7 @@
import numpy as np
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from tests.unit_tests.client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -1,6 +1,7 @@
import pytest
from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
DARK_PALETTE,
@@ -13,8 +14,6 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
SeverityKind,
)
from .client_mocks import mocked_client
@pytest.fixture
def toast(qtbot):

View File

@@ -1,372 +0,0 @@
import pytest
from qtpy.QtPdf import QPdfDocument
from qtpy.QtPdfWidgets import QPdfView
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
from .client_mocks import mocked_client
@pytest.fixture
def pdf_viewer_widget(qtbot, mocked_client):
"""Create a PDF viewer widget for testing."""
widget = PdfViewerWidget(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.cleanup()
@pytest.fixture
def temp_pdf_file(tmpdir):
"""Create a minimal 3-page PDF file for testing."""
pdf_content = b"""%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Page 1) Tj ET
endstream
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R >>
endobj
6 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Page 2) Tj ET
endstream
endobj
7 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 8 0 R >>
endobj
8 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Page 3) Tj ET
endstream
endobj
9 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 10
0000000000 65535 f
0000000010 00000 n
0000000060 00000 n
0000000125 00000 n
0000000205 00000 n
0000000282 00000 n
0000000362 00000 n
0000000439 00000 n
0000000519 00000 n
0000000596 00000 n
trailer
<< /Size 10 /Root 1 0 R >>
startxref
675
%%EOF
"""
pdf_path = tmpdir.join("test_3page.pdf")
pdf_path.write_binary(pdf_content)
return str(pdf_path)
@pytest.fixture
def temp_pdf_file_2(tmpdir):
"""Create a second minimal temporary PDF file for testing."""
# Create a minimal PDF content for testing
pdf_content = b"""%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Resources <<
/Font <<
/F1 <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
/Contents 4 0 R
>>
endobj
4 0 obj
<<
/Length 44
>>stream
BT
/F1 12 Tf
100 700 Td
(Second Test PDF) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000307 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
398
%%EOF"""
# Create temporary PDF file using tmpdir
pdf_file = tmpdir.join("test2.pdf")
pdf_file.write_binary(pdf_content)
return str(pdf_file)
def test_initialization(pdf_viewer_widget: PdfViewerWidget):
"""Test that the widget initializes correctly."""
widget = pdf_viewer_widget
# Check basic widget setup
assert widget is not None
assert hasattr(widget, "pdf_view")
assert hasattr(widget, "toolbar")
assert hasattr(widget, "_pdf_document")
# Check initial state
assert widget._current_file_path is None
assert widget._page_spacing == 5
assert widget._side_margins == 10
# Check PDF view setup
assert isinstance(widget.pdf_view, QPdfView)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
# Check PDF document setup
assert isinstance(widget._pdf_document, QPdfDocument)
def test_toolbar_setup(pdf_viewer_widget: PdfViewerWidget):
"""Test that toolbar is set up with all expected actions."""
widget = pdf_viewer_widget
toolbar = widget.toolbar
# Check that all expected actions exist
expected_actions = [
"open_file",
"zoom_in",
"zoom_out",
"fit_width",
"fit_page",
"reset_zoom",
"continuous_scroll",
"prev_page",
"next_page",
"page_jump",
]
for action_name in expected_actions:
assert toolbar.components.exists(action_name), f"Action {action_name} not found"
def test_load_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file, temp_pdf_file_2):
"""Test loading a PDF file into the viewer."""
widget = pdf_viewer_widget
# Load the temporary PDF file
widget.load_pdf(temp_pdf_file)
qtbot.wait(100) # Wait for loading
# Check that the document is loaded
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
assert widget._pdf_document.pageCount() > 0
assert widget._current_file_path == temp_pdf_file
# Load a second PDF file to test reloading
widget.load_pdf(temp_pdf_file_2)
qtbot.wait(100) # Wait for loading
# Check that the new document is loaded
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
assert widget._pdf_document.pageCount() > 0
assert widget._current_file_path == temp_pdf_file_2
assert widget.current_file_path == temp_pdf_file_2
widget.current_file_path = temp_pdf_file
qtbot.wait(100) # Wait for loading
assert widget.current_file_path == temp_pdf_file
def test_load_invalid_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, tmpdir):
"""Test loading an invalid PDF file into the viewer."""
widget = pdf_viewer_widget
# Try to open a non-existent file
invalid_pdf_file = tmpdir.join("non_existent.pdf")
# Attempt to load the invalid PDF file
with pytest.raises(FileNotFoundError):
widget.load_pdf(str(invalid_pdf_file), _override_slot_params={"raise_error": True})
def test_page_navigation(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test page navigation functionality."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Check initial page
assert widget.current_page == 1
total_pages = widget._pdf_document.pageCount()
assert total_pages >= 1
# Navigate to next page
widget.next_page()
qtbot.wait(300)
assert widget.current_page == 2
# Navigate to previous page
widget.previous_page()
qtbot.wait(300)
assert widget.current_page == 1
# Jump to last page
widget.jump_to_page(total_pages)
qtbot.wait(300)
assert widget.current_page == total_pages
widget.jump_to_page(1)
qtbot.wait(300)
assert widget.current_page == 1
widget.jump_to_page(2)
qtbot.wait(300)
assert widget.current_page == 2
widget.go_to_last_page()
qtbot.wait(300)
assert widget.current_page == total_pages
widget.go_to_first_page()
qtbot.wait(300)
assert widget.current_page == 1
widget.page_input.setText(str(total_pages + 10))
widget.page_input.returnPressed.emit()
qtbot.wait(100)
assert widget.current_page == total_pages
def test_zoom_controls(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test zoom in, zoom out, fit width, fit page, and reset zoom functionality."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Initial zoom mode should be FitToWidth
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
# Zoom in
initial_zoom = widget.pdf_view.zoomFactor()
widget.zoom_in()
qtbot.wait(100)
assert widget.pdf_view.zoomFactor() > initial_zoom
# Zoom out
zoom_after_in = widget.pdf_view.zoomFactor()
widget.zoom_out()
qtbot.wait(100)
assert widget.pdf_view.zoomFactor() < zoom_after_in
# Fit to page
widget.fit_to_page()
qtbot.wait(100)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitInView
# Fit to width
widget.fit_to_width()
qtbot.wait(100)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
# Reset zoom
widget.reset_zoom()
qtbot.wait(100)
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.Custom
def test_page_spacing_and_margins(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test setting page spacing and side margins."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Set and verify page spacing
widget.page_spacing = 20
assert widget.page_spacing == 20
# Set and verify side margins
widget.side_margins = 30
assert widget.side_margins == 30
def test_toggle_continuous_scroll(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
"""Test toggling continuous scroll mode."""
widget = pdf_viewer_widget
# Load the temporary PDF file
with qtbot.waitSignal(widget.document_ready, timeout=2000):
widget.load_pdf(temp_pdf_file)
# Initial mode should be single page
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
# Toggle to continuous scroll
widget.toggle_continuous_scroll(True)
qtbot.wait(100)
assert widget.pdf_view.pageMode() == QPdfView.PageMode.MultiPage
# Toggle back to single page
widget.toggle_continuous_scroll(False)
qtbot.wait(100)
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
widget.jump_to_page(2)
qtbot.wait(100)
assert widget.current_page == 2

View File

@@ -1,8 +1,8 @@
import numpy as np
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
from .client_mocks import mocked_client
from .conftest import create_widget
# pylint: disable=unused-import

View File

@@ -7,7 +7,8 @@ from qtpy.QtCore import Qt, QTimer
from qtpy.QtGui import QValidator
from qtpy.QtWidgets import QPushButton
from bec_widgets.tests.utils import Positioner
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.tests.fake_devices import Positioner
from bec_widgets.widgets.control.device_control.positioner_box import (
PositionerBox,
PositionerControlLine,
@@ -16,7 +17,6 @@ from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit
DeviceLineEdit,
)
from .client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -2,9 +2,9 @@ from unittest import mock
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox2D
from .client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -5,10 +5,9 @@ from unittest.mock import patch
import pytest
from qtpy.QtWidgets import QMessageBox
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
from .client_mocks import mocked_client
@pytest.fixture
def reset_button(qtbot, mocked_client):

View File

@@ -2,10 +2,9 @@
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
from .client_mocks import mocked_client
@pytest.fixture
def resume_button(qtbot, mocked_client):

View File

@@ -4,13 +4,12 @@ import pytest
from bec_lib.endpoints import MessageEndpoints
from pydantic import ValidationError
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils import Colors
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.progress.ring_progress_bar.ring import ProgressbarConnections, RingConfig
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBarConfig
from .client_mocks import mocked_client
@pytest.fixture
def ring_progress_bar(qtbot, mocked_client):

View File

@@ -7,12 +7,11 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage
from qtpy.QtCore import QModelIndex, Qt
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils.forms_from_types.items import StrFormItem
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl
from .client_mocks import mocked_client
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name

View File

@@ -2,10 +2,9 @@ from unittest import mock
import pytest
from bec_lib.messages import ScanHistoryMessage, _StoredDataInfo
from pytestqt import qtbot
from qtpy import QtCore
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.services.scan_history_browser.components import (
ScanHistoryDeviceViewer,
ScanHistoryMetadataViewer,
@@ -15,8 +14,6 @@ from bec_widgets.widgets.services.scan_history_browser.scan_history_browser impo
ScanHistoryBrowser,
)
from .client_mocks import mocked_client
@pytest.fixture
def scan_history_msg():

View File

@@ -4,6 +4,7 @@ import numpy as np
import pytest
from bec_lib import messages
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
ProgressState,
@@ -14,8 +15,6 @@ from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import (
ScanProgressBar,
)
from .client_mocks import mocked_client
@pytest.fixture
def scan_progressbar(qtbot, mocked_client):

View File

@@ -2,12 +2,12 @@ import json
import numpy as np
from bec_widgets.tests.client_mocks import create_dummy_scan_item, mocked_client
from bec_widgets.widgets.plots.scatter_waveform.scatter_curve import (
ScatterCurveConfig,
ScatterDeviceSignal,
)
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from tests.unit_tests.client_mocks import create_dummy_scan_item, mocked_client
from .conftest import create_widget

View File

@@ -4,15 +4,14 @@ from unittest.mock import MagicMock, patch
import pytest
from qtpy import QtCore
from qtpy.QtWidgets import QDialogButtonBox, QLabel
from qtpy.QtWidgets import QDialogButtonBox
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBaseConfig,
)
from bec_widgets.widgets.utility.signal_label.signal_label import ChoiceDialog, SignalLabel
from .client_mocks import mocked_client
SAMX_INFO_DICT = {
"signals": {
"readback": {

View File

@@ -2,10 +2,9 @@
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from .client_mocks import mocked_client
@pytest.fixture
def stop_button(qtbot, mocked_client):

View File

@@ -1,9 +1,8 @@
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.editors.text_box.text_box import DEFAULT_TEXT, TextBox
from .client_mocks import mocked_client
@pytest.fixture
def text_box_widget(qtbot, mocked_client):

View File

@@ -3,10 +3,10 @@ from unittest import mock
import pyqtgraph as pg
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from .client_mocks import mocked_client
from .conftest import create_widget

View File

@@ -1,10 +1,9 @@
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from .client_mocks import mocked_client
@pytest.fixture
def plot_widget_with_arrow_item(qtbot, mocked_client):

View File

@@ -5,10 +5,9 @@ from unittest import mock
import pytest
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from .client_mocks import mocked_client
@pytest.fixture
def vscode_widget(qtbot, mocked_client):

View File

@@ -12,13 +12,7 @@ from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication, QCheckBox, QDialog, QDialogButtonBox, QDoubleSpinBox
from bec_widgets.widgets.plots.plot_base import UIMode
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
)
from tests.unit_tests.client_mocks import (
from bec_widgets.tests.client_mocks import (
DummyData,
create_dummy_scan_item,
dap_plugin_message,
@@ -26,6 +20,12 @@ from tests.unit_tests.client_mocks import (
mocked_client,
mocked_client_with_dap,
)
from bec_widgets.widgets.plots.plot_base import UIMode
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
)
from .conftest import create_widget

View File

@@ -3,10 +3,9 @@ from unittest import mock
import pytest
from qtpy.QtNetwork import QAuthenticator
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.editors.web_console.web_console import WebConsole, _web_console_registry
from .client_mocks import mocked_client
@pytest.fixture
def console_widget(qtbot, mocked_client):

View File

@@ -1,10 +1,9 @@
import pytest
from qtpy.QtCore import QUrl
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.editors.website.website import WebsiteWidget
from .client_mocks import mocked_client
@pytest.fixture
def website_widget(qtbot, mocked_client):