mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 18:20:55 +02:00
Compare commits
7 Commits
fix/ide_vi
...
v2.43.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b005542df3 | ||
| 13a9175ba5 | |||
|
|
3f8e60a14f | ||
| 6bc1c3c5f1 | |||
|
|
9f91eb2e08 | ||
| 1e19092319 | |||
| 96664c3923 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,6 +1,33 @@
|
||||
# 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
|
||||
|
||||
- **image_roi**: Enhance get_coordinates to include rectangle center and dimensions
|
||||
([`96664c3`](https://github.com/bec-project/bec_widgets/commit/96664c3923737df0b09aa8f35df388f9fd630b55))
|
||||
|
||||
- **positioner_box_2d**: Added properties to enable/disable vertical and horizontal controls
|
||||
([`1e19092`](https://github.com/bec-project/bec_widgets/commit/1e190923196f8b28c92dfdd83b9ce90873dd792d))
|
||||
|
||||
|
||||
## v2.41.1 (2025-10-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -45,6 +45,7 @@ _Widgets = {
|
||||
"MonacoWidget": "MonacoWidget",
|
||||
"MotorMap": "MotorMap",
|
||||
"MultiWaveform": "MultiWaveform",
|
||||
"PdfViewerWidget": "PdfViewerWidget",
|
||||
"PositionIndicator": "PositionIndicator",
|
||||
"PositionerBox": "PositionerBox",
|
||||
"PositionerBox2D": "PositionerBox2D",
|
||||
@@ -3421,6 +3422,137 @@ 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."""
|
||||
|
||||
@@ -3534,6 +3666,34 @@ class PositionerBox2D(RPCBase):
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_controls_hor(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for horizontal control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
@enable_controls_hor.setter
|
||||
@rpc_call
|
||||
def enable_controls_hor(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for horizontal control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_controls_ver(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for vertical control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
@enable_controls_ver.setter
|
||||
@rpc_call
|
||||
def enable_controls_ver(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for vertical control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
|
||||
class PositionerControlLine(RPCBase):
|
||||
"""A widget that controls a single device."""
|
||||
@@ -3653,8 +3813,8 @@ class RectangularROI(RPCBase):
|
||||
@rpc_call
|
||||
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
|
||||
"""
|
||||
Returns the coordinates of a rectangle's corners. Supports returning them
|
||||
as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
Returns the coordinates of a rectangle's corners, rectangle center and dimensions.
|
||||
Supports returning them as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary with
|
||||
@@ -3662,7 +3822,7 @@ class RectangularROI(RPCBase):
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
dict | tuple: The rectangle's corner coordinates, rectangle center and dimensions, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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 QTimer
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
@@ -129,16 +129,44 @@ 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}")
|
||||
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
|
||||
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:
|
||||
res = method_obj(*args, **kwargs)
|
||||
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
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
|
||||
@@ -34,7 +34,15 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "screenshot"]
|
||||
USER_ACCESS = [
|
||||
"set_positioner_hor",
|
||||
"set_positioner_ver",
|
||||
"screenshot",
|
||||
"enable_controls_hor",
|
||||
"enable_controls_hor.setter",
|
||||
"enable_controls_ver",
|
||||
"enable_controls_ver.setter",
|
||||
]
|
||||
|
||||
device_changed_hor = Signal(str, str)
|
||||
device_changed_ver = Signal(str, str)
|
||||
@@ -63,6 +71,8 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
self._limits_hor = None
|
||||
self._limits_ver = None
|
||||
self._dialog = None
|
||||
self._enable_controls_hor = True
|
||||
self._enable_controls_ver = True
|
||||
if self.current_path == "":
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
self.init_ui()
|
||||
@@ -281,6 +291,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
self.on_device_readback_hor,
|
||||
self._device_ui_components_hv("horizontal"),
|
||||
)
|
||||
self._apply_controls_enabled("horizontal")
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_change_ver(self, old_device: str, new_device: str):
|
||||
@@ -300,6 +311,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
self.on_device_readback_ver,
|
||||
self._device_ui_components_hv("vertical"),
|
||||
)
|
||||
self._apply_controls_enabled("vertical")
|
||||
|
||||
def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents:
|
||||
if device == "horizontal":
|
||||
@@ -337,6 +349,25 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
if device == self.device_ver:
|
||||
return self._device_ui_components_hv("vertical")
|
||||
|
||||
def _apply_controls_enabled(self, axis: DeviceId):
|
||||
state = self._enable_controls_hor if axis == "horizontal" else self._enable_controls_ver
|
||||
if axis == "horizontal":
|
||||
widgets = [
|
||||
self.ui.tweak_increase_hor,
|
||||
self.ui.tweak_decrease_hor,
|
||||
self.ui.step_increase_hor,
|
||||
self.ui.step_decrease_hor,
|
||||
]
|
||||
else:
|
||||
widgets = [
|
||||
self.ui.tweak_increase_ver,
|
||||
self.ui.tweak_decrease_ver,
|
||||
self.ui.step_increase_ver,
|
||||
self.ui.step_decrease_ver,
|
||||
]
|
||||
for w in widgets:
|
||||
w.setEnabled(state)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback_hor(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
@@ -417,6 +448,26 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
"""Step size for tweak"""
|
||||
self.ui.step_size_ver.setValue(val)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_controls_hor(self) -> bool:
|
||||
"""Persisted switch for horizontal control buttons (tweak/step)."""
|
||||
return self._enable_controls_hor
|
||||
|
||||
@enable_controls_hor.setter
|
||||
def enable_controls_hor(self, value: bool):
|
||||
self._enable_controls_hor = value
|
||||
self._apply_controls_enabled("horizontal")
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_controls_ver(self) -> bool:
|
||||
"""Persisted switch for vertical control buttons (tweak/step)."""
|
||||
return self._enable_controls_ver
|
||||
|
||||
@enable_controls_ver.setter
|
||||
def enable_controls_ver(self, value: bool):
|
||||
self._enable_controls_ver = value
|
||||
self._apply_controls_enabled("vertical")
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_inc_hor(self):
|
||||
"""Tweak device a up"""
|
||||
|
||||
@@ -558,8 +558,8 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
|
||||
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
|
||||
"""
|
||||
Returns the coordinates of a rectangle's corners. Supports returning them
|
||||
as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
Returns the coordinates of a rectangle's corners, rectangle center and dimensions.
|
||||
Supports returning them as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary with
|
||||
@@ -567,13 +567,17 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
dict | tuple: The rectangle's corner coordinates, rectangle center and dimensions, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
x_left, y_bottom, x_right, y_top = self._normalized_edges()
|
||||
width = x_right - x_left
|
||||
height = y_top - y_bottom
|
||||
cx = x_left + width / 2
|
||||
cy = y_bottom + height / 2
|
||||
|
||||
if typed:
|
||||
return {
|
||||
@@ -581,8 +585,19 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
"bottom_right": (x_right, y_bottom),
|
||||
"top_left": (x_left, y_top),
|
||||
"top_right": (x_right, y_top),
|
||||
"center_x": cx,
|
||||
"center_y": cy,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
return (x_left, y_bottom), (x_right, y_bottom), (x_left, y_top), (x_right, y_top)
|
||||
return (
|
||||
(x_left, y_bottom),
|
||||
(x_right, y_bottom),
|
||||
(x_left, y_top),
|
||||
(x_right, y_top),
|
||||
(cx, cy),
|
||||
(width, height),
|
||||
)
|
||||
|
||||
def _lookup_scene_image(self):
|
||||
"""
|
||||
|
||||
574
bec_widgets/widgets/utility/pdf_viewer/pdf_viewer.py
Normal file
574
bec_widgets/widgets/utility/pdf_viewer/pdf_viewer.py
Normal file
@@ -0,0 +1,574 @@
|
||||
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 1–100000
|
||||
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())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['pdf_viewer.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from 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()
|
||||
@@ -0,0 +1,17 @@
|
||||
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()
|
||||
BIN
docs/assets/widget_screenshots/pdf_viewer.png
Normal file
BIN
docs/assets/widget_screenshots/pdf_viewer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 498 KiB |
119
docs/user/widgets/pdf_viewer/pdf_viewer_widget.md
Normal file
119
docs/user/widgets/pdf_viewer/pdf_viewer_widget.md
Normal file
@@ -0,0 +1,119 @@
|
||||
(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:
|
||||
```
|
||||
````
|
||||
@@ -270,6 +270,14 @@ 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}
|
||||
@@ -307,6 +315,7 @@ 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
|
||||
|
||||
|
||||
```
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.41.1"
|
||||
version = "2.43.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_data_extraction_matches_coordinates(bec_image_widget_with_roi):
|
||||
|
||||
# For rectangular ROI: pixel bounding box equals coordinate bbox
|
||||
if isinstance(roi, RectangularROI):
|
||||
(x0, y0), (_, _), (_, _), (x1, y1) = roi.get_coordinates(typed=False)
|
||||
(x0, y0), (_, _), (_, _), (x1, y1), *_ = roi.get_coordinates(typed=False)
|
||||
# ensure ints inside image shape
|
||||
x0, y0, x1, y1 = map(int, (x0, y0, x1, y1))
|
||||
expected = widget.main_image.image[y0:y1, x0:x1]
|
||||
|
||||
372
tests/unit_tests/test_pdf_viewer.py
Normal file
372
tests/unit_tests/test_pdf_viewer.py
Normal file
@@ -0,0 +1,372 @@
|
||||
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
|
||||
@@ -80,3 +80,60 @@ def test_positioner_box_setpoint_changes(positioner_box_2d: PositionerBox2D):
|
||||
positioner_box_2d.ui.setpoint_ver.setText("100")
|
||||
positioner_box_2d.on_setpoint_change_ver()
|
||||
mock_move.assert_called_once_with(100, relative=False)
|
||||
|
||||
|
||||
def _hor_buttons(widget: PositionerBox2D):
|
||||
return [
|
||||
widget.ui.tweak_increase_hor,
|
||||
widget.ui.tweak_decrease_hor,
|
||||
widget.ui.step_increase_hor,
|
||||
widget.ui.step_decrease_hor,
|
||||
]
|
||||
|
||||
|
||||
def _ver_buttons(widget: PositionerBox2D):
|
||||
return [
|
||||
widget.ui.tweak_increase_ver,
|
||||
widget.ui.tweak_decrease_ver,
|
||||
widget.ui.step_increase_ver,
|
||||
widget.ui.step_decrease_ver,
|
||||
]
|
||||
|
||||
|
||||
def test_controls_default_enabled(positioner_box_2d: PositionerBox2D):
|
||||
"""By default both axes controls are enabled and UI reflects it."""
|
||||
assert positioner_box_2d.enable_controls_hor is True
|
||||
assert positioner_box_2d.enable_controls_ver is True
|
||||
assert all(w.isEnabled() for w in _hor_buttons(positioner_box_2d))
|
||||
assert all(w.isEnabled() for w in _ver_buttons(positioner_box_2d))
|
||||
|
||||
|
||||
def test_disable_enable_controls_and_persist_across_device_change(
|
||||
positioner_box_2d: PositionerBox2D, qtbot
|
||||
):
|
||||
"""Disabling an axis should disable its buttons and remain disabled after device (re)binding."""
|
||||
# Disable horizontal and verify UI
|
||||
positioner_box_2d.enable_controls_hor = False
|
||||
assert positioner_box_2d.enable_controls_hor is False
|
||||
assert all(not w.isEnabled() for w in _hor_buttons(positioner_box_2d))
|
||||
|
||||
# Simulate a horizontal device change; state must persist after queued re-apply
|
||||
positioner_box_2d.on_device_change_hor("samx", "samx")
|
||||
qtbot.waitUntil(lambda: all(not w.isEnabled() for w in _hor_buttons(positioner_box_2d)))
|
||||
|
||||
# Re-enable and verify UI
|
||||
positioner_box_2d.enable_controls_hor = True
|
||||
qtbot.waitUntil(lambda: all(w.isEnabled() for w in _hor_buttons(positioner_box_2d)))
|
||||
|
||||
# Disable vertical and verify UI
|
||||
positioner_box_2d.enable_controls_ver = False
|
||||
assert positioner_box_2d.enable_controls_ver is False
|
||||
assert all(not w.isEnabled() for w in _ver_buttons(positioner_box_2d))
|
||||
|
||||
# Simulate a vertical device change; state must persist after queued re-apply
|
||||
positioner_box_2d.on_device_change_ver("samy", "samy")
|
||||
qtbot.waitUntil(lambda: all(not w.isEnabled() for w in _ver_buttons(positioner_box_2d)))
|
||||
|
||||
# Re-enable and verify UI
|
||||
positioner_box_2d.enable_controls_ver = True
|
||||
qtbot.waitUntil(lambda: all(w.isEnabled() for w in _ver_buttons(positioner_box_2d)))
|
||||
|
||||
Reference in New Issue
Block a user