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

Compare commits

..

1 Commits

Author SHA1 Message Date
161c1570bd wip - feat(file browser): add file browser widget 2025-07-24 17:22:53 +02:00
12 changed files with 389 additions and 93 deletions

View File

@@ -1,35 +1,6 @@
# CHANGELOG
## v2.30.6 (2025-07-26)
### Bug Fixes
- **waveform**: Autorange is applied with 150ms delay after curve is added
([`61e5bde`](https://github.com/bec-project/bec_widgets/commit/61e5bde15f0e1ebe185ddbe81cd71ad581ae6009))
## v2.30.5 (2025-07-25)
### Bug Fixes
- **positioner-box**: Test to fix handling of none integer values for precision
([`b718b43`](https://github.com/bec-project/bec_widgets/commit/b718b438bacff6eb6cd6015f1a67dcf75c05dce4))
### Refactoring
- **positioner-box**: Cleanup, accept float precision
([`4d5df96`](https://github.com/bec-project/bec_widgets/commit/4d5df9608a9438b9f6d7508c323eb3772e53f37d))
## v2.30.4 (2025-07-25)
### Bug Fixes
- **cli**: Remove stderr from cli output when not using rpc
([`b4e0664`](https://github.com/bec-project/bec_widgets/commit/b4e0664011682cae9966aa2632210a6b60e11714))
## v2.30.3 (2025-07-23)
### Bug Fixes

View File

@@ -37,6 +37,7 @@ _Widgets = {
"DeviceBrowser": "DeviceBrowser",
"DeviceComboBox": "DeviceComboBox",
"DeviceLineEdit": "DeviceLineEdit",
"FileBrowser": "FileBrowser",
"Heatmap": "Heatmap",
"Image": "Image",
"LogPanel": "LogPanel",
@@ -1183,6 +1184,16 @@ class EllipticalROI(RPCBase):
"""
class FileBrowser(RPCBase):
"""A simple file browser widget."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class Heatmap(RPCBase):
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""

View File

@@ -51,7 +51,7 @@ def _filter_output(output: str) -> str:
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
stream_buffer = {process.stdout: [], process.stderr: []}
try:
os.set_blocking(process.stdout.fileno(), False)

View File

@@ -138,11 +138,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
signals = msg_content.get("signals", {})
# pylint: disable=protected-access
hinted_signals = self.dev[device]._hints
precision = getattr(self.dev[device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
precision = self.dev[device].precision
spinner = ui_components["spinner"]
position_indicator = ui_components["position_indicator"]
@@ -182,13 +178,11 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
spinner.setVisible(False)
if readback_val is not None:
text = f"{readback_val:.{precision}f}"
readback.setText(text)
readback.setText(f"{readback_val:.{precision}f}")
position_emit(readback_val)
if setpoint_val is not None:
text = f"{setpoint_val:.{precision}f}"
setpoint.setText(text)
setpoint.setText(f"{setpoint_val:.{precision}f}")
limits = self.dev[device].limits
limit_update(limits)
@@ -211,13 +205,10 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
ui["readback"].setToolTip(f"{device} readback")
ui["setpoint"].setToolTip(f"{device} setpoint")
ui["step_size"].setToolTip(f"Step size for {device}")
precision = getattr(self.dev[device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
ui["step_size"].setDecimals(precision)
ui["step_size"].setValue(10**-precision * 10)
precision = self.dev[device].precision
if precision is not None:
ui["step_size"].setDecimals(precision)
ui["step_size"].setValue(10**-precision * 10)
def _swap_readback_signal_connection(self, slot, old_device, new_device):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))

View File

@@ -45,12 +45,7 @@ class PositionerGroupBox(QGroupBox):
def _on_position_update(self, pos: float):
self.position_update.emit(pos)
precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
self.widget.label = f"{pos:.{precision}f}"
self.widget.label = f"%.{self.widget.dev[self.widget.device].precision}f" % pos
def close(self):
self.widget.close()

View File

@@ -908,10 +908,6 @@ class Waveform(PlotBase):
self.roi_enable.emit(True) # Enable the ROI toolbar action
self.request_dap() # Request DAP update directly without blocking proxy
QTimer.singleShot(
150, self.auto_range
) # autorange with a delay to ensure the plot is updated
return curve
def _add_curve_object(self, name: str, config: CurveConfig) -> Curve:

View File

@@ -0,0 +1,298 @@
from PySide6.QtWidgets import QVBoxLayout
from qtpy.QtCore import QDir
from qtpy.QtWidgets import QApplication, QFileSystemModel, QHeaderView, QTreeView, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
class FileBrowser(BECWidget, QWidget):
"""
A simple file browser widget.
"""
PLUGIN = True
ICON_NAME = "folder_open"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.tree = QTreeView(self)
self.model = QFileSystemModel()
self.tree.setModel(self.model)
self.tree.setRootIndex(self.model.index(QDir.rootPath()))
self.model.setRootPath(QDir.rootPath())
self._allow_changing_root = True
self._original_root_path = QDir.rootPath()
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self._go_back_action = MaterialIconAction("arrow_back", "Go Back", parent=self)
self.toolbar.components.add_safe("go_back", self._go_back_action)
self.toolbar.components.add_safe(
"refresh", MaterialIconAction("refresh", "Refresh", parent=self)
)
self.toolbar.components.add_safe(
"open", MaterialIconAction("folder_open", "Open File", parent=self)
)
bundle = ToolbarBundle("file_io", self.toolbar.components)
bundle.add_action("go_back")
bundle.add_action("refresh")
bundle.add_action("open")
self.toolbar.add_bundle(bundle)
self.toolbar.show_bundles(["file_io"])
layout.addWidget(self.toolbar)
layout.addWidget(self.tree)
self.setLayout(layout)
self._show_hidden_files = False
self._go_back_action.action.setEnabled(False)
self._go_back_action.action.triggered.connect(self._on_go_back)
self.tree.setSelectionMode(QTreeView.SelectionMode.SingleSelection)
self.tree.doubleClicked.connect(self._on_double_click)
def _on_double_click(self, index):
"""
Handle double-click events on the file browser.
Opens the selected file or directory.
"""
if not index.isValid():
return
path = self.model.filePath(index)
if self.model.isDir(index) and self._allow_changing_root:
self.tree.setRootIndex(index)
if path != self._original_root_path:
self._go_back_action.action.setEnabled(True)
else:
self._go_back_action.action.setEnabled(False)
return
print(f"Opening file: {path}")
def _on_go_back(self):
"""
Handle the go back action.
Navigates to the previous directory in the file browser.
"""
if self._allow_changing_root and self.tree.rootIndex().isValid():
parent_index = self.tree.rootIndex().parent()
if parent_index.isValid():
self.tree.setRootIndex(parent_index)
if parent_index != self.model.index(self._original_root_path):
self._go_back_action.action.setEnabled(True)
else:
self._go_back_action.action.setEnabled(False)
@SafeProperty(bool)
def show_toolbar(self):
"""
Get whether the toolbar is shown in the file browser.
"""
return not self.toolbar.isHidden()
@show_toolbar.setter
def show_toolbar(self, show: bool):
"""
Set whether the toolbar is shown in the file browser.
"""
self.toolbar.setVisible(show)
@SafeProperty(bool)
def show_hidden_files(self):
"""
Get whether hidden files are shown in the file browser.
"""
return self._show_hidden_files
@show_hidden_files.setter
def show_hidden_files(self, show: bool):
"""
Set whether hidden files are shown in the file browser.
"""
self._show_hidden_files = show
if show:
self.model.setFilter(
QDir.Filter.AllDirs
| QDir.Filter.Files
| QDir.Filter.NoDotAndDotDot
| QDir.Filter.Hidden
)
else:
self.model.setFilter(
QDir.Filter.AllDirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot
)
self.tree.setRootIndex(self.model.index(self.model.rootPath()))
@SafeProperty(bool)
def allow_changing_root(self):
"""
Get whether changing the root path is allowed.
"""
return self._allow_changing_root
@allow_changing_root.setter
def allow_changing_root(self, allow: bool):
"""
Set whether changing the root path is allowed.
"""
self._allow_changing_root = allow
@SafeProperty(bool)
def show_file_size(self):
"""
Get whether the file size is shown in the file browser.
"""
index = self._section_index("Size")
return not self.tree.header().isSectionHidden(index)
@show_file_size.setter
def show_file_size(self, show: bool):
"""
Set whether the file size is shown in the file browser.
"""
index = self._section_index("Size")
self.tree.header().setSectionHidden(index, not show)
self.tree.header().repaint()
@SafeProperty(bool)
def show_file_kind(self):
"""
Get whether the file kind is shown in the file browser.
"""
index = self._section_index("Kind")
return not self.tree.header().isSectionHidden(index)
@show_file_kind.setter
def show_file_kind(self, show: bool):
"""
Set whether the file kind is shown in the file browser.
"""
index = self._section_index("Kind")
self.tree.header().setSectionHidden(index, not show)
self.tree.setRootIndex(self.model.index(self.model.rootPath()))
@SafeProperty(bool)
def show_file_timestamp(self):
"""
Get whether the file timestamp is shown in the file browser.
"""
index = self._section_index("Date Modified")
return not self.tree.header().isSectionHidden(index)
@show_file_timestamp.setter
def show_file_timestamp(self, show: bool):
"""
Set whether the file timestamp is shown in the file browser.
"""
index = self._section_index("Date Modified")
self.tree.header().setSectionHidden(index, not show)
self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
self.tree.setRootIndex(self.model.index(self.model.rootPath()))
@SafeProperty(str)
def root_path(self):
"""
Get the root path of the file browser.
"""
return self.model.rootPath()
@root_path.setter
def root_path(self, path: str):
"""
Set the root path of the file browser.
"""
self.model.setRootPath(path)
self.tree.setRootIndex(self.model.index(path))
self._original_root_path = path
@SafeProperty(bool)
def show_header(self):
"""
Get whether the header is shown in the file browser.
"""
return not self.tree.header().isHidden()
@show_header.setter
def show_header(self, show: bool):
"""
Set whether the header is shown in the file browser.
"""
self.tree.setHeaderHidden(not show)
self.tree.setRootIndex(self.model.index(self.model.rootPath()))
def _section_index(self, label: str) -> int:
header = self.tree.header()
model = self.tree.model()
for i in range(model.columnCount()):
if model.headerData(i, header.orientation()) == label:
return i
print(f"Section '{label}' not found in header.")
return -1
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
file_browser = FileBrowser()
file_browser.root_path = "/Users/wakonig_k/software/work/bec-widgets/bec_widgets"
file_browser.show_file_size = False
file_browser.show_file_kind = False
file_browser.show_file_timestamp = False
file_browser.show_hidden_files = True
file_browser.show_header = False
file_browser.show()
sys.exit(app.exec_())
# from qtpy.QtCore import Qt
# from qtpy.QtWidgets import QDockWidget, QFileSystemModel, QTreeView
# class ExplorerDock(QWidget):
# def __init__(self, cpath, themes):
# super().__init__()
# self._themes = themes
# self.setWindowTitle("Explorer")
# self.tree = QTreeView(self)
# self.model = QFileSystemModel()
# self.tree.setModel(self.model)
# self.tree.setRootIndex(self.model.index(cpath))
# self.model.setRootPath(cpath)
# layout = QVBoxLayout(self)
# layout.setContentsMargins(0, 0, 0, 0)
# layout.addWidget(self.tree)
# app = QApplication([])
# explorer = ExplorerDock(
# cpath="/Users/wakonig_k/software/work/csaxs_bec/csaxs_bec", themes={"sidebar_bg": "#2E2E2E"}
# )
# explorer.show()
# app.exec_()
# # self.dock = QDockWidget("Explorer", self)
# # self.dock.setMinimumWidth(200)
# # self.dock.visibilityChanged.connect(
# # lambda visible: self.onExplorerDockVisibilityChanged(visible)
# # )
# # self.dock.setAllowedAreas(Qt.DockWidgetArea.AllDockWidgetAreas)
# # tree_view = QTreeView()
# # self.model = QFileSystemModel()
# # bg = self._themes["sidebar_bg"]
# # tree_view.setStyleSheet(
# # f"QTreeView {{background-color: {bg}; color: white; border: none; }}"
# # )
# # tree_view.setModel(self.model)
# # tree_view.setRootIndex(self.model.index(cpath))
# # self.model.setRootPath(cpath)

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.file_browser.file_browser import FileBrowser
DOM_XML = """
<ui language='c++'>
<widget class='FileBrowser' name='file_browser'>
</widget>
</ui>
"""
class FileBrowserPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = FileBrowser(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(FileBrowser.ICON_NAME)
def includeFile(self):
return "file_browser"
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 "FileBrowser"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.file_browser.file_browser_plugin import FileBrowserPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(FileBrowserPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

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

View File

@@ -7,7 +7,6 @@ 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.widgets.control.device_control.positioner_box import (
PositionerBox,
PositionerControlLine,
@@ -20,18 +19,6 @@ from .client_mocks import mocked_client
from .conftest import create_widget
class PositionerWithoutPrecision(Positioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, precision, name="test", limits=None, read_value=1.0, enabled=True):
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
self._precision = precision
@property
def precision(self):
return self._precision
@pytest.fixture
def positioner_box(qtbot, mocked_client):
"""Fixture for PositionerBox widget"""
@@ -178,26 +165,3 @@ def test_device_validity_check_rejects_non_positioner():
positioner_box = mock.MagicMock(spec=PositionerBox)
positioner_box.dev = {"test": 5.123}
assert not PositionerBox._check_device_is_valid(positioner_box, "test")
def test_positioner_box_device_without_precision(qtbot, positioner_box):
"""Test positioner box with device without precision"""
for ii, mock_return in enumerate([None, 2, 2.0, True, "tmp"]):
dev_name = f"samy_{ii}"
device = PositionerWithoutPrecision(
precision=mock_return, name=dev_name, limits=[-5, 5], read_value=3.0
)
positioner_box.bec_dispatcher.client.device_manager.add_devices(devices=[device])
positioner_box.device = dev_name
def check_title():
return positioner_box.ui.device_box.title() == dev_name
qtbot.waitUntil(check_title, timeout=3000)
if isinstance(mock_return, (int, float)):
mock_return = int(mock_return)
assert positioner_box.ui.step_size.value() == 10**-mock_return * 10
else:
assert positioner_box.ui.step_size.value() == 10**-8 * 10