From d4edb4e3af2cd0feec2009a4a8c7d4db52812651 Mon Sep 17 00:00:00 2001 From: hitz_s Date: Thu, 11 Jun 2026 15:29:13 +0200 Subject: [PATCH 1/5] proof of concept --- debye_bec/bec_widgets/widgets/client.py | 25 + .../widgets/data_viewer/__init__.py | 0 .../widgets/data_viewer/data_viewer.py | 237 ++++++++ .../widgets/data_viewer/data_viewer.pyproject | 1 + .../widgets/data_viewer/data_viewer_plugin.py | 57 ++ .../widgets/data_viewer/qt_widgets.py | 177 ++++++ .../data_viewer/register_data_viewer.py | 15 + .../bec_widgets/widgets/data_viewer/viewer.py | 531 ++++++++++++++++++ .../bec_widgets/widgets/designer_plugins.py | 2 + 9 files changed, 1045 insertions(+) create mode 100644 debye_bec/bec_widgets/widgets/data_viewer/__init__.py create mode 100644 debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py create mode 100644 debye_bec/bec_widgets/widgets/data_viewer/data_viewer.pyproject create mode 100644 debye_bec/bec_widgets/widgets/data_viewer/data_viewer_plugin.py create mode 100644 debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py create mode 100644 debye_bec/bec_widgets/widgets/data_viewer/register_data_viewer.py create mode 100644 debye_bec/bec_widgets/widgets/data_viewer/viewer.py diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py index cb2df5d..c82894c 100644 --- a/debye_bec/bec_widgets/widgets/client.py +++ b/debye_bec/bec_widgets/widgets/client.py @@ -13,10 +13,35 @@ logger = bec_logger.logger _Widgets = { + "DataViewer": "DataViewer", "DigitalTwin": "DigitalTwin", } +class DataViewer(RPCBase): + """Main widget of Data Viewer""" + + _IMPORT_MODULE = "debye_bec.bec_widgets.widgets.data_viewer.data_viewer" + + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + class DigitalTwin(RPCBase): """Main widget of Digital Twin""" diff --git a/debye_bec/bec_widgets/widgets/data_viewer/__init__.py b/debye_bec/bec_widgets/widgets/data_viewer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py new file mode 100644 index 0000000..2a3c5a2 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py @@ -0,0 +1,237 @@ +""" +Data Viewer: Custom BEC widget to view data from scans. +""" + +import sys +from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints +from bec_widgets.utils.bec_dispatcher import BECDispatcher +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import apply_theme, get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot + +# pylint: disable=E0611 +from qtpy.QtWidgets import ( + QApplication, + QWidget, +) + +from functools import partial + +from bec_widgets.utils.colors import get_accent_colors +from qtpy.QtCore import Qt + +# pylint: disable=E0611 +from qtpy.QtGui import QFont +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QDoubleSpinBox, + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, + QLayout, +) + +from debye_bec.bec_widgets.widgets.data_viewer.qt_widgets import TaggedListWidget +from debye_bec.bec_widgets.widgets.data_viewer.viewer import HDF5Viewer + +logger = bec_logger.logger + + +class DataViewer(BECWidget, QWidget): + """ + Main widget of Data Viewer + """ + + PLUGIN = True + ICON_NAME = "lightbulb" + + def __init__(self, *arg, parent=None, **kwargs): + super().__init__(parent=parent, theme_update=True, *arg, **kwargs) + self.get_bec_shortcuts() + + central = QWidget() + self.root_layout = QVBoxLayout(central) + + self.input = InputPanel() + self.viewer = HDF5Viewer() + + self.root_layout.addWidget(self.input, alignment=Qt.AlignmentFlag.AlignTop) + self.root_layout.addWidget(self.viewer, alignment=Qt.AlignmentFlag.AlignTop) + + self.setLayout(self.root_layout) + self.setWindowTitle("Data Viewer") + # self.resize(1800, 800) + + self.history = [] + self.bec_dispatcher.connect_slot( + self.on_history_update, MessageEndpoints.scan_history() + ) + self.on_history_update() + + self.current_row = 0 + + self.input.scan_sel.currentItemChanged_connect(self.scan_sel_changed) + self.input.load_button.clicked_connect(self.load_dataset) + + @SafeSlot() + def scan_sel_changed(self, *_, **kwargs): + self.current_row = kwargs['value']().row() + + @SafeSlot() + def load_dataset(self, *_): + scan = self.history[self.current_row] + file = scan['file_components'][0] + b'_master.' + scan['file_components'][1] + logger.info(file.decode()) + self.viewer.load_file(file.decode()) + + @SafeSlot() + def on_history_update(self, *_): + self.history = [] + self.input.scan_sel.clear() + for n in range(1, 10): # last 10 scans + scan_data = self.client.history[-n].metadata['bec'] + # logger.info(scan_data) + self.history.append({ + 'scan_number': scan_data['scan_number'], + 'scan_name': scan_data['scan_name'], + 'user_metadata': scan_data['user_metadata'], + 'file_components': scan_data['file_components'], + }) + self.input.scan_sel.addTaggedItem( + label=str(scan_data['scan_number']), + tags=[ + (scan_data['scan_name'], "#4A90D9"), + ], + ) + + +class InputPanel(QWidget): + """Panel for scan selection of the data viewer widget""" + + def __init__(self, parent=None): + super().__init__(parent) + self._layout = QVBoxLayout(self) + self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore + + # Scan selection + self.scan_sel = ListWidget("scan_sel", "Scan", ["Si", "Rh", "Pt"]) + self.load_button = Button(label_button='Load Dataset', enabled=True) + + # Assemble complete scan selection group + self.input_group = Group( + "Scan selection", + [ + self.scan_sel, + self.load_button, + ], + ) + + self._layout.addWidget(self.input_group) + self._layout.addStretch() + + + +class Group(QGroupBox): + def __init__(self, label, widgets): + super().__init__(label) + self.layout = QVBoxLayout(self) # type: ignore + for widget in widgets: + self.layout.addWidget(widget) # type: ignore + + +class ListWidget(QWidget): + def __init__(self, identifier="", label="", enums=[]): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.identifier = identifier + # self.label = QLabel(label) + # self.label.setFixedWidth(140) + # self.label.setContentsMargins(0, 0, 10, 0) + # self.label.setWordWrap(True) + # layout.addWidget(self.label) + self.value = TaggedListWidget() + self.value.setFixedWidth(400) + # for entry in enums: + # self.value.addItem(entry) + layout.addWidget(self.value) + + def clear(self): + self.value.clear() + + def addTaggedItem(self, label, tags): + self.value.addTaggedItem(label, tags) + + def setCurrentIndex(self, text): + self.value.setCurrentIndex(text) + + # def currentIndex(self) -> int: + # return self.value.currentIndex() + + # def has_focus(self) -> bool: + # return QApplication.focusWidget() is self.value.view() + + def currentItemChanged_connect(self, func): + """Connect a function to the Enter/Return key press.""" + self.value.currentItemChanged.connect( + partial( + func, + identifier=self.identifier, + value_obj=self.value, + value=lambda: self.value.currentIndex(), + ) + ) + + def setDisabled(self, disable): + self.value.setDisabled(disable) + + +class Button(QWidget): + def __init__(self, label=None, label_button: str = "", enabled=False): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + if label is not None: + self.label = QLabel(label) + self.label.setFixedWidth(140) + layout.addWidget(self.label) + self.button = QPushButton(label_button) + if label is not None: + self.button.setFixedWidth(160) + self.enable_button(enabled) + layout.addWidget(self.button) + + def clicked_connect(self, func): + """Connect a function to the button press.""" + self.button.clicked.connect(func) + + def enable_button(self, enable: bool = False): + if enable: + self.button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) + self.button.setEnabled(True) + else: # disabled + self.button.setStyleSheet( + "QPushButton {{background-color: rgb(120, 120, 120); color: white;}}" + ) + self.button.setDisabled(True) + + def setText(self, text): + self.button.setText(text) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + apply_theme("light") + dispatcher = BECDispatcher(gui_id="data_viewer") + win = DataViewer() + win.show() + sys.exit(app.exec_()) diff --git a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.pyproject b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.pyproject new file mode 100644 index 0000000..e6d7fe4 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.pyproject @@ -0,0 +1 @@ +{'files': ['data_viewer.py']} \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer_plugin.py b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer_plugin.py new file mode 100644 index 0000000..cf5b92f --- /dev/null +++ b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from bec_widgets.utils.bec_designer import designer_material_icon +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from debye_bec.bec_widgets.widgets.data_viewer.data_viewer import DataViewer + +DOM_XML = """ + + + + +""" + + +class DataViewerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = DataViewer(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(DataViewer.ICON_NAME) + + def includeFile(self): + return "data_viewer" + + 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 "DataViewer" + + def toolTip(self): + return "DataViewer" + + def whatsThis(self): + return self.toolTip() diff --git a/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py b/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py new file mode 100644 index 0000000..fa9235c --- /dev/null +++ b/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py @@ -0,0 +1,177 @@ +""" +TaggedListWidget — a QListWidget where each item shows a label + styled tag pills. +Selection works natively; no popup, no paintEvent hacks. +""" + +import sys +from qtpy.QtWidgets import ( + QApplication, QListWidget, QListWidgetItem, QStyledItemDelegate, + QStyleOptionViewItem, QWidget, QVBoxLayout, QLabel, QStyle, +) +from qtpy.QtGui import QPainter, QColor, QFont, QPen, QFontMetrics +from qtpy.QtCore import Qt, QRect, QSize, QPoint + +# ── Design tokens ────────────────────────────────────────────────────────────── +ITEM_HEIGHT = 40 +H_PAD = 12 +TAG_H_PAD = 7 +TAG_V_PAD = 3 +TAG_GAP = 5 +LABEL_TAG_GAP = 12 +CORNER_RADIUS = 4 +FONT_FAMILY = "Segoe UI" +LABEL_FONT_SIZE = 10 +TAG_FONT_SIZE = 8 +TAG_TEXT_COLOR = "#FFFFFF" + +# ── Enum compat (PySide6 nested vs PyQt5 flat) ──────────────────────────────── +try: + _DEMIBOLD = QFont.Weight.DemiBold + _MEDIUM = QFont.Weight.Medium +except AttributeError: + _DEMIBOLD = QFont.DemiBold + _MEDIUM = QFont.Medium + +_AlignVCenter = Qt.AlignmentFlag.AlignVCenter if hasattr(Qt, "AlignmentFlag") else Qt.AlignVCenter +_AlignCenter = Qt.AlignmentFlag.AlignCenter if hasattr(Qt, "AlignmentFlag") else Qt.AlignCenter +_UserRole = Qt.ItemDataRole.UserRole if hasattr(Qt, "ItemDataRole") else Qt.UserRole +_NoPen = Qt.PenStyle.NoPen if hasattr(Qt, "PenStyle") else Qt.NoPen +_AA = (QPainter.RenderHint.Antialiasing + if hasattr(QPainter, "RenderHint") else QPainter.Antialiasing) + +try: + _State_Selected = QStyle.StateFlag.State_Selected + _State_MouseOver = QStyle.StateFlag.State_MouseOver +except AttributeError: + _State_Selected = QStyle.State_Selected + _State_MouseOver = QStyle.State_MouseOver + + +# ── Delegate ────────────────────────────────────────────────────────────────── +class TaggedDelegate(QStyledItemDelegate): + + def sizeHint(self, option: QStyleOptionViewItem, index) -> QSize: + return QSize(option.rect.width() or 300, ITEM_HEIGHT) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index) -> None: + painter.save() + + is_selected = bool(option.state & _State_Selected) + is_hover = bool(option.state & _State_MouseOver) + + # Background + if is_selected: + painter.fillRect(option.rect, option.palette.highlight()) + elif is_hover: + painter.fillRect(option.rect, QColor("#F1F5F9")) + else: + painter.fillRect(option.rect, option.palette.base()) + + label_text = index.data(Qt.ItemDataRole.DisplayRole + if hasattr(Qt, "ItemDataRole") else Qt.DisplayRole) or "" + tags: list = index.data(_UserRole) or [] + + # Label + label_font = QFont(FONT_FAMILY, LABEL_FONT_SIZE) + label_font.setWeight(_DEMIBOLD) + painter.setFont(label_font) + label_color = (option.palette.highlightedText().color() + if is_selected else option.palette.text().color()) + painter.setPen(QPen(label_color)) + + fm = QFontMetrics(label_font) + label_w = fm.horizontalAdvance(label_text) + label_y = option.rect.top() + (ITEM_HEIGHT - fm.height()) // 2 + fm.ascent() + painter.drawText(QPoint(option.rect.left() + H_PAD, label_y), label_text) + + # Tag pills + tag_font = QFont(FONT_FAMILY, TAG_FONT_SIZE) + tag_font.setWeight(_MEDIUM) + fm_tag = QFontMetrics(tag_font) + + x = option.rect.left() + H_PAD + label_w + LABEL_TAG_GAP + for tag_text, hex_color in tags: + tag_text = str(tag_text) + tw = fm_tag.horizontalAdvance(tag_text) + 2 * TAG_H_PAD + th = fm_tag.height() + 2 * TAG_V_PAD + ty = option.rect.top() + (ITEM_HEIGHT - th) // 2 + pill = QRect(x, ty, tw, th) + + fill = QColor(hex_color) + if is_selected: + fill = fill.lighter(140) + + painter.setRenderHint(_AA) + painter.setPen(_NoPen) + painter.setBrush(fill) + painter.drawRoundedRect(pill, CORNER_RADIUS, CORNER_RADIUS) + + painter.setFont(tag_font) + painter.setPen(QPen(QColor(TAG_TEXT_COLOR))) + painter.drawText(pill, _AlignCenter, tag_text) + + x += tw + TAG_GAP + + painter.restore() + + +# ── Widget ──────────────────────────────────────────────────────────────────── +class TaggedListWidget(QListWidget): + """QListWidget with label + coloured tag pills per row.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setItemDelegate(TaggedDelegate(self)) + self.setMouseTracking(True) # enables hover highlight + + def addTaggedItem(self, label: str, tags: list | None = None) -> QListWidgetItem: + """ + Add a row. tags is a list of (text, hex_color) pairs, e.g. + [("v1.26", "#2563EB"), ("stable", "#16A34A")] + """ + item = QListWidgetItem(str(label)) + if tags: + item.setData(_UserRole, list(tags)) + self.addItem(item) + return item + + def currentTags(self) -> list: + item = self.currentItem() + return item.data(_UserRole) or [] if item else [] + + +# ── Demo ────────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setStyle("Fusion") + + window = QWidget() + window.setWindowTitle("TaggedListWidget demo") + window.resize(420, 320) + + layout = QVBoxLayout(window) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(12) + + lst = TaggedListWidget() + lst.addTaggedItem("NumPy", tags=[("1.26", "#2563EB"), ("stable", "#16A34A")]) + lst.addTaggedItem("Pandas", tags=[("2.2", "#7C3AED"), ("deprecated", "#DC2626")]) + lst.addTaggedItem("PyTorch", tags=[("2.3", "#2563EB"), ("cuda", "#DC2626")]) + lst.addTaggedItem("scikit-learn", tags=[("1.4", "#0891B2"), ("stable", "#16A34A")]) + lst.addTaggedItem("Matplotlib", tags=[("3.8", "#D97706")]) + lst.addTaggedItem("Plain item — no tags") + layout.addWidget(lst) + + info = QLabel() + + def on_selection(): + label = lst.currentItem().text() if lst.currentItem() else "" + tags = lst.currentTags() + tag_str = " ".join(t for t, _ in tags) if tags else "none" + info.setText(f"Selected: {label} tags: {tag_str}") + + lst.currentItemChanged.connect(lambda *_: on_selection()) + layout.addWidget(info) + + window.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/data_viewer/register_data_viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/register_data_viewer.py new file mode 100644 index 0000000..4ead24d --- /dev/null +++ b/debye_bec/bec_widgets/widgets/data_viewer/register_data_viewer.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from debye_bec.bec_widgets.widgets.data_viewer.data_viewer_plugin import DataViewerPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(DataViewerPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py new file mode 100644 index 0000000..d1ee5e1 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +""" +HDF5 Viewer — PyQt5 + pyqtgraph + h5py +Usage: python hdf5_viewer.py +""" + +import sys +import os +import numpy as np +import h5py + +from qtpy.QtWidgets import ( + QApplication, QMainWindow, QSplitter, QTreeWidget, QTreeWidgetItem, + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, + QTableWidgetItem, QAbstractItemView, QSizePolicy, QStatusBar, + QRadioButton, QGroupBox, QHeaderView, +) +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont, QColor + +import pyqtgraph as pg + +# ── Palette ──────────────────────────────────────────────────────────────── +BG_DARK = "#1C1E26" +BG_MID = "#252731" +BG_PANEL = "#2D3040" +ACCENT = "#7C9CF5" +TEXT_PRI = "#E8EAF2" +TEXT_SEC = "#8A8FA8" +BORDER = "#3A3D50" +SEL_BG = "#3A4268" +GROUP_BG = "#21232D" +SUCCESS = "#5FCEA8" + +# pyqtgraph color tuples (R, G, B) +PG_BG = (28, 30, 38) +PG_PLOT_BG = (45, 48, 64) +PG_ACCENT = (124, 156, 245) +PG_GRID = (58, 61, 80) +PG_TEXT = (138, 143, 168) + +STYLESHEET = f""" +QMainWindow, QWidget {{ + background-color: {BG_DARK}; + color: {TEXT_PRI}; + font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', sans-serif; + font-size: 13px; +}} +QSplitter::handle {{ + background: {BORDER}; + width: 1px; height: 1px; +}} +QTreeWidget {{ + background-color: {BG_MID}; + border: 1px solid {BORDER}; + border-radius: 6px; + outline: none; + padding: 4px; +}} +QTreeWidget::item {{ padding: 4px 6px; border-radius: 4px; }} +QTreeWidget::item:hover {{ background: {BG_PANEL}; }} +QTreeWidget::item:selected {{ background: {SEL_BG}; color: {TEXT_PRI}; }} +QHeaderView::section {{ + background-color: {BG_PANEL}; color: {TEXT_SEC}; + border: none; padding: 4px 6px; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; +}} +QTableWidget {{ + background-color: {BG_MID}; + border: 1px solid {BORDER}; + border-radius: 6px; + gridline-color: {BORDER}; + outline: none; +}} +QTableWidget::item {{ padding: 3px 8px; color: {TEXT_PRI}; }} +QTableWidget::item:selected {{ background: {SEL_BG}; }} +QTableCornerButton::section {{ background: {BG_PANEL}; border: none; }} +QLabel#section_title {{ + font-size: 11px; font-weight: 600; color: {TEXT_SEC}; + text-transform: uppercase; letter-spacing: 0.08em; padding: 2px 0 6px 2px; +}} +QLabel#info_label {{ color: {TEXT_SEC}; font-size: 12px; padding: 4px; }} +QLabel#path_label {{ color: {ACCENT}; font-size: 12px; font-weight: 500; padding: 2px 4px; }} +QPushButton {{ + background-color: {BG_PANEL}; color: {TEXT_PRI}; + border: 1px solid {BORDER}; border-radius: 5px; padding: 5px 14px; font-size: 12px; +}} +QPushButton:hover {{ background-color: {SEL_BG}; border-color: {ACCENT}; }} +QPushButton:pressed {{ background-color: {ACCENT}; color: white; }} +QRadioButton {{ color: {TEXT_PRI}; spacing: 6px; font-size: 12px; }} +QRadioButton::indicator {{ + width: 14px; height: 14px; border-radius: 7px; + border: 2px solid {BORDER}; background: {BG_MID}; +}} +QRadioButton::indicator:checked {{ background: {ACCENT}; border-color: {ACCENT}; }} +QGroupBox {{ + background: {GROUP_BG}; border: 1px solid {BORDER}; border-radius: 6px; + margin-top: 10px; padding: 8px; font-size: 11px; color: {TEXT_SEC}; +}} +QGroupBox::title {{ + subcontrol-origin: margin; left: 8px; padding: 0 4px; + color: {TEXT_SEC}; text-transform: uppercase; letter-spacing: 0.06em; font-size: 10px; +}} +QStatusBar {{ + background: {BG_PANEL}; color: {TEXT_SEC}; + border-top: 1px solid {BORDER}; font-size: 11px; +}} +QScrollBar:vertical {{ background: {BG_MID}; width: 8px; border-radius: 4px; }} +QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 4px; min-height: 20px; }} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} +QScrollBar:horizontal {{ background: {BG_MID}; height: 8px; border-radius: 4px; }} +QScrollBar::handle:horizontal {{ background: {BORDER}; border-radius: 4px; min-width: 20px; }} +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} +""" + + +# ── pyqtgraph global config ──────────────────────────────────────────────── +pg.setConfigOptions( + background=pg.mkColor(*PG_BG), + foreground=pg.mkColor(*PG_TEXT), + antialias=True, + useOpenGL=False, # safer default; set True for large datasets if GPU available +) + + +def _styled_plot_widget(**kwargs) -> pg.PlotWidget: + """Return a PlotWidget already themed to match the dark palette.""" + pw = pg.PlotWidget(**kwargs) + pw.setBackground(pg.mkColor(*PG_PLOT_BG)) + ax = pw.getPlotItem() + for side in ("left", "bottom", "right", "top"): + ax.getAxis(side).setPen(pg.mkPen(color=PG_GRID, width=1)) + ax.getAxis(side).setTextPen(pg.mkPen(color=PG_TEXT)) + ax.showGrid(x=True, y=True, alpha=0.25) + return pw + + +# ── HDF5 Tree helpers ────────────────────────────────────────────────────── +TYPE_ICONS = {"group": "📂", "dataset": "📊"} + +def dtype_tag(ds): + d = ds.dtype + if np.issubdtype(d, np.integer): return "int" + if np.issubdtype(d, np.floating): return "float" + if np.issubdtype(d, np.complexfloating): return "complex" + if d.kind in ("S", "U", "O"): return "str" + return str(d) + + +def populate_tree(parent_item, h5_obj): + if not isinstance(h5_obj, h5py.Group): + return + for key in h5_obj.keys(): + child = h5_obj[key] + if isinstance(child, h5py.Group): + label = f"{TYPE_ICONS['group']} {key}" + item = QTreeWidgetItem(parent_item, [label, "Group", ""]) + item.setData(0, Qt.UserRole, child.name) + item.setData(0, Qt.UserRole + 1, "group") + item.setForeground(0, QColor(ACCENT)) + populate_tree(item, child) + elif isinstance(child, h5py.Dataset): + shape_str = "×".join(str(s) for s in child.shape) or "scalar" + label = f"{TYPE_ICONS['dataset']} {key}" + item = QTreeWidgetItem(parent_item, [label, dtype_tag(child), shape_str]) + item.setData(0, Qt.UserRole, child.name) + item.setData(0, Qt.UserRole + 1, "dataset") + item.setForeground(1, QColor(SUCCESS)) + item.setForeground(2, QColor(TEXT_SEC)) + + +# ── Data / Plot panel ────────────────────────────────────────────────────── +class DataPanel(QWidget): + def __init__(self): + super().__init__() + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(8) + + # Header + hdr = QHBoxLayout() + self.path_label = QLabel("Select a dataset from the tree") + self.path_label.setObjectName("path_label") + self.info_label = QLabel("") + self.info_label.setObjectName("info_label") + hdr.addWidget(self.path_label, 1) + hdr.addWidget(self.info_label) + self._layout.addLayout(hdr) + + # View-mode selector + mode_box = QGroupBox("View mode") + mode_layout = QHBoxLayout(mode_box) + mode_layout.setContentsMargins(8, 4, 8, 4) + self.rb_auto = QRadioButton("Auto") + self.rb_plot = QRadioButton("Plot") + self.rb_image = QRadioButton("Image") + self.rb_table = QRadioButton("Table") + self.rb_auto.setChecked(True) + for rb in (self.rb_auto, self.rb_plot, self.rb_image, self.rb_table): + mode_layout.addWidget(rb) + rb.toggled.connect(self._on_mode_change) + mode_layout.addStretch() + self._layout.addWidget(mode_box) + + # Content stack + self.stack = QWidget() + self.stack_layout = QVBoxLayout(self.stack) + self.stack_layout.setContentsMargins(0, 0, 0, 0) + self._layout.addWidget(self.stack, 1) + + self._current_data = None + self._show_empty() + + # ── helpers ─────────────────────────────────────────────────────────── + def _clear_stack(self): + while self.stack_layout.count(): + item = self.stack_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + def _show_empty(self): + self._clear_stack() + lbl = QLabel("No data selected") + lbl.setObjectName("info_label") + lbl.setAlignment(Qt.AlignCenter) + self.stack_layout.addWidget(lbl) + + def _on_mode_change(self): + if self._current_data is not None: + self.display(self._current_data, self.path_label.text()) + + def _active_mode(self): + if self.rb_plot.isChecked(): return "plot" + if self.rb_image.isChecked(): return "image" + if self.rb_table.isChecked(): return "table" + return "auto" + + # ── public ──────────────────────────────────────────────────────────── + def display(self, data, path=""): + self._current_data = data + self.path_label.setText(path) + + if not isinstance(data, np.ndarray): + data = np.array(data) + + self.info_label.setText( + f"shape {data.shape} · dtype {data.dtype} · {data.size:,} elements" + ) + + mode = self._active_mode() + if mode == "auto": + if data.ndim <= 1 and data.size > 1: + mode = "plot" + elif data.ndim == 2 and min(data.shape) > 1: + mode = "image" + else: + mode = "table" + + if mode == "plot": + self._show_plot_1d(data) + elif mode == "image": + self._show_image_2d(data) + else: + self._show_table(data) + + # ── 1-D line plot ────────────────────────────────────────────────────── + def _show_plot_1d(self, data): + self._clear_stack() + flat = data.flatten().astype(float) + + pw = _styled_plot_widget() + pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # Filled area under curve + x = np.arange(len(flat), dtype=float) + fill = pg.FillBetweenItem( + pg.PlotDataItem(x, flat), + pg.PlotDataItem(x, np.zeros_like(flat)), + brush=pg.mkBrush(124, 156, 245, 35), + ) + pw.addItem(fill) + pw.plot(x, flat, + pen=pg.mkPen(color=PG_ACCENT, width=1.6), + antialias=True) + + pw.getPlotItem().setLabel("bottom", "index") + pw.getPlotItem().setLabel("left", "value") + + self.stack_layout.addWidget(pw) + + # ── 2-D image ────────────────────────────────────────────────────────── + def _show_image_2d(self, data): + self._clear_stack() + + squeezed = np.squeeze(data) + if squeezed.ndim > 2: + squeezed = squeezed.reshape(-1, squeezed.shape[-1]) + + # complex → magnitude + img_data = np.abs(squeezed) if np.iscomplexobj(squeezed) else squeezed.astype(float) + + # ImageView gives us colorbar + histogram + zoom for free + iv = pg.ImageView() + iv.setBackground(pg.mkColor(*PG_BG)) + iv.ui.histogram.setBackground(pg.mkColor(*PG_PLOT_BG)) + iv.ui.roiBtn.hide() + iv.ui.menuBtn.hide() + + # Use 'inferno'-like LUT + iv.setColorMap(pg.colormap.get("inferno", source="matplotlib")) + + # pyqtgraph ImageView expects (cols, rows) — transpose so row 0 is at top + iv.setImage(img_data.T, autoLevels=True, autoHistogramRange=True) + iv.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.stack_layout.addWidget(iv) + + # ── Table ────────────────────────────────────────────────────────────── + def _show_table(self, data): + self._clear_stack() + + MAX_ROWS, MAX_COLS = 2000, 500 + + if data.ndim == 0: + flat = data.reshape(1, 1) + elif data.ndim == 1: + flat = data.reshape(-1, 1) + elif data.ndim == 2: + flat = data + else: + flat = data.reshape(-1, data.shape[-1]) + + rows, cols = flat.shape + show_rows = min(rows, MAX_ROWS) + show_cols = min(cols, MAX_COLS) + + if rows > MAX_ROWS or cols > MAX_COLS: + note = QLabel(f"⚠ Showing {show_rows}/{rows} rows × {show_cols}/{cols} cols") + note.setObjectName("info_label") + note.setAlignment(Qt.AlignCenter) + self.stack_layout.addWidget(note) + + table = QTableWidget(show_rows, show_cols) + table.setEditTriggers(QAbstractItemView.NoEditTriggers) + table.setSelectionMode(QAbstractItemView.ContiguousSelection) + table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + table.horizontalHeader().setDefaultSectionSize(90) + table.verticalHeader().setDefaultSectionSize(22) + table.setFont(QFont("JetBrains Mono, Consolas, monospace", 10)) + + is_float = np.issubdtype(flat.dtype, np.floating) + is_complex = np.iscomplexobj(flat) + + for r in range(show_rows): + for c in range(show_cols): + val = flat[r, c] + txt = f"{val:.6g}" if is_float else (f"{val:.4g}" if is_complex else str(val)) + cell = QTableWidgetItem(txt) + cell.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + table.setItem(r, c, cell) + + self.stack_layout.addWidget(table) + + +# ── Attributes panel ─────────────────────────────────────────────────────── +class AttrsPanel(QWidget): + def __init__(self): + super().__init__() + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + title = QLabel("Attributes") + title.setObjectName("section_title") + layout.addWidget(title) + self.table = QTableWidget(0, 2) + self.table.setHorizontalHeaderLabels(["Key", "Value"]) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.verticalHeader().hide() + self.table.verticalHeader().setDefaultSectionSize(22) + layout.addWidget(self.table) + + def set_attrs(self, attrs_dict): + self.table.setRowCount(0) + for key, val in attrs_dict.items(): + row = self.table.rowCount() + self.table.insertRow(row) + self.table.setItem(row, 0, QTableWidgetItem(str(key))) + self.table.setItem(row, 1, QTableWidgetItem(str(val))) + + +# ── Main window ──────────────────────────────────────────────────────────── +class HDF5Viewer(QMainWindow): + def __init__(self, filepath=None): + super().__init__() + self.filepath = filepath + self.h5file = None + # self.setWindowTitle(f"HDF5 Viewer — {os.path.basename(filepath)}") + # self.resize(1280, 800) + self._build_ui() + + def load_file(self, f): + self.filepath = f + self.h5file = h5py.File(f, "r") + self._load_tree() + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + root_layout = QVBoxLayout(central) + root_layout.setContentsMargins(10, 10, 10, 6) + root_layout.setSpacing(6) + + # Top bar + top_bar = QHBoxLayout() + # file_label = QLabel(f"📁 {self.filepath}") + # file_label.setObjectName("path_label") + # top_bar.addWidget(file_label) + top_bar.addStretch() + root_layout.addLayout(top_bar) + + # Main horizontal splitter + h_split = QSplitter(Qt.Horizontal) + h_split.setHandleWidth(2) + + # ── Left: tree + attrs ── + left_pane = QWidget() + left_layout = QVBoxLayout(left_pane) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.setSpacing(6) + + tree_title = QLabel("Structure") + tree_title.setObjectName("section_title") + left_layout.addWidget(tree_title) + + self.tree = QTreeWidget() + self.tree.setHeaderLabels(["Name", "Type", "Shape"]) + self.tree.header().setStretchLastSection(False) + self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + self.tree.header().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.tree.header().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.tree.setIndentation(16) + self.tree.itemClicked.connect(self._on_item_clicked) + left_layout.addWidget(self.tree, 3) + + self.attrs_panel = AttrsPanel() + left_layout.addWidget(self.attrs_panel, 1) + + h_split.addWidget(left_pane) + + # ── Right: data panel ── + self.data_panel = DataPanel() + h_split.addWidget(self.data_panel) + + h_split.setStretchFactor(0, 1) + h_split.setStretchFactor(1, 3) + h_split.setSizes([320, 960]) + + root_layout.addWidget(h_split, 1) + + # self.status = QStatusBar() + # self.setStatusBar(self.status) + # self.status.showMessage(f"Loaded: {self.filepath}") + + def _load_tree(self): + self.tree.clear() + + root_item = QTreeWidgetItem(self.tree, ["📂 /", "Group", ""]) + root_item.setData(0, Qt.UserRole, "/") + root_item.setData(0, Qt.UserRole + 1, "group") + root_item.setForeground(0, QColor(ACCENT)) + + populate_tree(root_item, self.h5file) + self.tree.addTopLevelItem(root_item) + + # Expand first 2 levels + self.tree.expandItem(root_item) + for i in range(root_item.childCount()): + self.tree.expandItem(root_item.child(i)) + + self.tree.setCurrentItem(root_item) + + def _on_item_clicked(self, item, _col): + path = item.data(0, Qt.UserRole) + kind = item.data(0, Qt.UserRole + 1) + if not path: + return + + obj = self.h5file[path] if path != "/" else self.h5file + attrs = dict(obj.attrs) if obj.attrs else {} + self.attrs_panel.set_attrs(attrs) + + if kind == "dataset": + ds = self.h5file[path] + data = ds[()] + self.data_panel.display(data, path) + self.status.showMessage( + f"{path} | shape {ds.shape} | dtype {ds.dtype}" + ) + else: + n = len(obj) if isinstance(obj, h5py.Group) else 0 + # self.status.showMessage( + # f"{path} | Group | {n} items | {len(attrs)} attributes" + # ) + + def closeEvent(self, event): + self.h5file.close() + super().closeEvent(event) + + +# ── Entry point ──────────────────────────────────────────────────────────── +def main(): + if len(sys.argv) < 2: + print("Usage: python hdf5_viewer.py ") + sys.exit(1) + + filepath = sys.argv[1] + if not os.path.isfile(filepath): + print(f"Error: file not found — {filepath}") + sys.exit(1) + + app = QApplication(sys.argv) + app.setApplicationName("HDF5 Viewer") + app.setStyleSheet(STYLESHEET) + + window = HDF5Viewer(filepath) + window.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/designer_plugins.py b/debye_bec/bec_widgets/widgets/designer_plugins.py index c941b27..5855291 100644 --- a/debye_bec/bec_widgets/widgets/designer_plugins.py +++ b/debye_bec/bec_widgets/widgets/designer_plugins.py @@ -5,9 +5,11 @@ from __future__ import annotations # pylint: skip-file designer_plugins = { + "DataViewer": ("debye_bec.bec_widgets.widgets.data_viewer.data_viewer", "DataViewer"), "DigitalTwin": ("debye_bec.bec_widgets.widgets.digital_twin.digital_twin", "DigitalTwin"), } widget_icons = { + "DataViewer": "lightbulb", "DigitalTwin": "lightbulb", } -- 2.54.0 From c64e6867ff3a12bb7c3d86bd35846275b49c5a6d Mon Sep 17 00:00:00 2001 From: hitz_s Date: Thu, 11 Jun 2026 15:39:34 +0200 Subject: [PATCH 2/5] wip --- .../bec_widgets/widgets/data_viewer/viewer.py | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py index d1ee5e1..0260616 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py @@ -17,6 +17,7 @@ from qtpy.QtWidgets import ( ) from qtpy.QtCore import Qt from qtpy.QtGui import QFont, QColor +from qtpy.QtWidgets import QStyle import pyqtgraph as pg @@ -43,8 +44,8 @@ STYLESHEET = f""" QMainWindow, QWidget {{ background-color: {BG_DARK}; color: {TEXT_PRI}; - font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', sans-serif; - font-size: 13px; + # font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', sans-serif; + # font-size: 13px; }} QSplitter::handle {{ background: {BORDER}; @@ -148,23 +149,39 @@ def dtype_tag(ds): def populate_tree(parent_item, h5_obj): + style = QApplication.style() + folder_icon = style.standardIcon(QStyle.SP_DirIcon) + file_icon = style.standardIcon(QStyle.SP_FileIcon) + if not isinstance(h5_obj, h5py.Group): return + for key in h5_obj.keys(): - child = h5_obj[key] + try: + child = h5_obj[key] + except Exception: + continue + if isinstance(child, h5py.Group): - label = f"{TYPE_ICONS['group']} {key}" - item = QTreeWidgetItem(parent_item, [label, "Group", ""]) + item = QTreeWidgetItem(parent_item, [key, "Group", ""]) + item.setIcon(0, folder_icon) + item.setData(0, Qt.UserRole, child.name) item.setData(0, Qt.UserRole + 1, "group") + item.setForeground(0, QColor(ACCENT)) + populate_tree(item, child) + elif isinstance(child, h5py.Dataset): shape_str = "×".join(str(s) for s in child.shape) or "scalar" - label = f"{TYPE_ICONS['dataset']} {key}" - item = QTreeWidgetItem(parent_item, [label, dtype_tag(child), shape_str]) + + item = QTreeWidgetItem(parent_item, [key, dtype_tag(child), shape_str]) + item.setIcon(0, file_icon) + item.setData(0, Qt.UserRole, child.name) item.setData(0, Qt.UserRole + 1, "dataset") + item.setForeground(1, QColor(SUCCESS)) item.setForeground(2, QColor(TEXT_SEC)) @@ -464,7 +481,10 @@ class HDF5Viewer(QMainWindow): def _load_tree(self): self.tree.clear() - root_item = QTreeWidgetItem(self.tree, ["📂 /", "Group", ""]) + style = QApplication.style() + folder_icon = style.standardIcon(QStyle.SP_DirIcon) # TODO Choose different icon for head + root_item = QTreeWidgetItem(self.tree, ["/", "Group", ""]) + root_item.setIcon(0, folder_icon) root_item.setData(0, Qt.UserRole, "/") root_item.setData(0, Qt.UserRole + 1, "group") root_item.setForeground(0, QColor(ACCENT)) @@ -493,9 +513,9 @@ class HDF5Viewer(QMainWindow): ds = self.h5file[path] data = ds[()] self.data_panel.display(data, path) - self.status.showMessage( - f"{path} | shape {ds.shape} | dtype {ds.dtype}" - ) + # self.status.showMessage( + # f"{path} | shape {ds.shape} | dtype {ds.dtype}" + # ) else: n = len(obj) if isinstance(obj, h5py.Group) else 0 # self.status.showMessage( -- 2.54.0 From a1ce2a43ece9823a55f8df08ad72795e9a21ec89 Mon Sep 17 00:00:00 2001 From: x01da Date: Fri, 12 Jun 2026 22:04:02 +0200 Subject: [PATCH 3/5] wip --- .../widgets/data_viewer/data_viewer.py | 67 ++- .../bec_widgets/widgets/data_viewer/viewer.py | 384 +++++++----------- 2 files changed, 165 insertions(+), 286 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py index 2a3c5a2..9ed8458 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py @@ -3,26 +3,20 @@ Data Viewer: Custom BEC widget to view data from scans. """ import sys +from functools import partial + from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import apply_theme, get_accent_colors from bec_widgets.utils.error_popups import SafeSlot - -# pylint: disable=E0611 -from qtpy.QtWidgets import ( - QApplication, - QWidget, -) - -from functools import partial - -from bec_widgets.utils.colors import get_accent_colors from qtpy.QtCore import Qt # pylint: disable=E0611 from qtpy.QtGui import QFont + +# pylint: disable=E0611 from qtpy.QtWidgets import ( QApplication, QComboBox, @@ -30,10 +24,10 @@ from qtpy.QtWidgets import ( QGroupBox, QHBoxLayout, QLabel, + QLayout, QPushButton, QVBoxLayout, QWidget, - QLayout, ) from debye_bec.bec_widgets.widgets.data_viewer.qt_widgets import TaggedListWidget @@ -52,7 +46,7 @@ class DataViewer(BECWidget, QWidget): def __init__(self, *arg, parent=None, **kwargs): super().__init__(parent=parent, theme_update=True, *arg, **kwargs) - self.get_bec_shortcuts() + self.get_bec_shortcuts() central = QWidget() self.root_layout = QVBoxLayout(central) @@ -60,17 +54,15 @@ class DataViewer(BECWidget, QWidget): self.input = InputPanel() self.viewer = HDF5Viewer() - self.root_layout.addWidget(self.input, alignment=Qt.AlignmentFlag.AlignTop) - self.root_layout.addWidget(self.viewer, alignment=Qt.AlignmentFlag.AlignTop) + self.root_layout.addWidget(self.input, 0) + self.root_layout.addWidget(self.viewer, 1) self.setLayout(self.root_layout) self.setWindowTitle("Data Viewer") # self.resize(1800, 800) self.history = [] - self.bec_dispatcher.connect_slot( - self.on_history_update, MessageEndpoints.scan_history() - ) + self.bec_dispatcher.connect_slot(self.on_history_update, MessageEndpoints.scan_history()) self.on_history_update() self.current_row = 0 @@ -80,12 +72,12 @@ class DataViewer(BECWidget, QWidget): @SafeSlot() def scan_sel_changed(self, *_, **kwargs): - self.current_row = kwargs['value']().row() + self.current_row = kwargs["value"]().row() @SafeSlot() def load_dataset(self, *_): scan = self.history[self.current_row] - file = scan['file_components'][0] + b'_master.' + scan['file_components'][1] + file = scan["file_components"][0] + b"_master." + scan["file_components"][1] logger.info(file.decode()) self.viewer.load_file(file.decode()) @@ -93,20 +85,22 @@ class DataViewer(BECWidget, QWidget): def on_history_update(self, *_): self.history = [] self.input.scan_sel.clear() - for n in range(1, 10): # last 10 scans - scan_data = self.client.history[-n].metadata['bec'] + # Get the length of the scan history, which is 0 when the bec server was started + # and no scan has finished yet. Limit the history to the latest 20 scans. + max_scans = min(len(self.client.history), 20) + for n in range(1, max_scans): # last scans, up to 20 + scan_data = self.client.history[-n].metadata["bec"] # logger.info(scan_data) - self.history.append({ - 'scan_number': scan_data['scan_number'], - 'scan_name': scan_data['scan_name'], - 'user_metadata': scan_data['user_metadata'], - 'file_components': scan_data['file_components'], - }) + self.history.append( + { + "scan_number": scan_data["scan_number"], + "scan_name": scan_data["scan_name"], + "user_metadata": scan_data["user_metadata"], + "file_components": scan_data["file_components"], + } + ) self.input.scan_sel.addTaggedItem( - label=str(scan_data['scan_number']), - tags=[ - (scan_data['scan_name'], "#4A90D9"), - ], + label=str(scan_data["scan_number"]), tags=[(scan_data["scan_name"], "#4A90D9")] ) @@ -120,22 +114,15 @@ class InputPanel(QWidget): # Scan selection self.scan_sel = ListWidget("scan_sel", "Scan", ["Si", "Rh", "Pt"]) - self.load_button = Button(label_button='Load Dataset', enabled=True) + self.load_button = Button(label_button="Load Dataset", enabled=True) # Assemble complete scan selection group - self.input_group = Group( - "Scan selection", - [ - self.scan_sel, - self.load_button, - ], - ) + self.input_group = Group("Scan selection", [self.scan_sel, self.load_button]) self._layout.addWidget(self.input_group) self._layout.addStretch() - class Group(QGroupBox): def __init__(self, label, widgets): super().__init__(label) diff --git a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py index 0260616..52f73e3 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py @@ -1,126 +1,54 @@ -#!/usr/bin/env python3 """ -HDF5 Viewer — PyQt5 + pyqtgraph + h5py -Usage: python hdf5_viewer.py +HDF5 Viewer — qtpy + pyqtgraph + h5py """ -import sys -import os -import numpy as np import h5py - -from qtpy.QtWidgets import ( - QApplication, QMainWindow, QSplitter, QTreeWidget, QTreeWidgetItem, - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, - QTableWidgetItem, QAbstractItemView, QSizePolicy, QStatusBar, - QRadioButton, QGroupBox, QHeaderView, -) -from qtpy.QtCore import Qt -from qtpy.QtGui import QFont, QColor -from qtpy.QtWidgets import QStyle - +import numpy as np import pyqtgraph as pg +from bec_lib import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import Qt +from qtpy.QtGui import QColor, QFont +from qtpy.QtWidgets import ( + QAbstractItemView, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QMainWindow, + QRadioButton, + QSizePolicy, + QSplitter, + QTableWidget, + QTableWidgetItem, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +logger = bec_logger.logger # ── Palette ──────────────────────────────────────────────────────────────── -BG_DARK = "#1C1E26" -BG_MID = "#252731" -BG_PANEL = "#2D3040" -ACCENT = "#7C9CF5" -TEXT_PRI = "#E8EAF2" +ACCENT = "#7C9CF5" TEXT_SEC = "#8A8FA8" -BORDER = "#3A3D50" -SEL_BG = "#3A4268" -GROUP_BG = "#21232D" -SUCCESS = "#5FCEA8" +SUCCESS = "#5FCEA8" # pyqtgraph color tuples (R, G, B) -PG_BG = (28, 30, 38) -PG_PLOT_BG = (45, 48, 64) -PG_ACCENT = (124, 156, 245) -PG_GRID = (58, 61, 80) -PG_TEXT = (138, 143, 168) - -STYLESHEET = f""" -QMainWindow, QWidget {{ - background-color: {BG_DARK}; - color: {TEXT_PRI}; - # font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', sans-serif; - # font-size: 13px; -}} -QSplitter::handle {{ - background: {BORDER}; - width: 1px; height: 1px; -}} -QTreeWidget {{ - background-color: {BG_MID}; - border: 1px solid {BORDER}; - border-radius: 6px; - outline: none; - padding: 4px; -}} -QTreeWidget::item {{ padding: 4px 6px; border-radius: 4px; }} -QTreeWidget::item:hover {{ background: {BG_PANEL}; }} -QTreeWidget::item:selected {{ background: {SEL_BG}; color: {TEXT_PRI}; }} -QHeaderView::section {{ - background-color: {BG_PANEL}; color: {TEXT_SEC}; - border: none; padding: 4px 6px; - font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; -}} -QTableWidget {{ - background-color: {BG_MID}; - border: 1px solid {BORDER}; - border-radius: 6px; - gridline-color: {BORDER}; - outline: none; -}} -QTableWidget::item {{ padding: 3px 8px; color: {TEXT_PRI}; }} -QTableWidget::item:selected {{ background: {SEL_BG}; }} -QTableCornerButton::section {{ background: {BG_PANEL}; border: none; }} -QLabel#section_title {{ - font-size: 11px; font-weight: 600; color: {TEXT_SEC}; - text-transform: uppercase; letter-spacing: 0.08em; padding: 2px 0 6px 2px; -}} -QLabel#info_label {{ color: {TEXT_SEC}; font-size: 12px; padding: 4px; }} -QLabel#path_label {{ color: {ACCENT}; font-size: 12px; font-weight: 500; padding: 2px 4px; }} -QPushButton {{ - background-color: {BG_PANEL}; color: {TEXT_PRI}; - border: 1px solid {BORDER}; border-radius: 5px; padding: 5px 14px; font-size: 12px; -}} -QPushButton:hover {{ background-color: {SEL_BG}; border-color: {ACCENT}; }} -QPushButton:pressed {{ background-color: {ACCENT}; color: white; }} -QRadioButton {{ color: {TEXT_PRI}; spacing: 6px; font-size: 12px; }} -QRadioButton::indicator {{ - width: 14px; height: 14px; border-radius: 7px; - border: 2px solid {BORDER}; background: {BG_MID}; -}} -QRadioButton::indicator:checked {{ background: {ACCENT}; border-color: {ACCENT}; }} -QGroupBox {{ - background: {GROUP_BG}; border: 1px solid {BORDER}; border-radius: 6px; - margin-top: 10px; padding: 8px; font-size: 11px; color: {TEXT_SEC}; -}} -QGroupBox::title {{ - subcontrol-origin: margin; left: 8px; padding: 0 4px; - color: {TEXT_SEC}; text-transform: uppercase; letter-spacing: 0.06em; font-size: 10px; -}} -QStatusBar {{ - background: {BG_PANEL}; color: {TEXT_SEC}; - border-top: 1px solid {BORDER}; font-size: 11px; -}} -QScrollBar:vertical {{ background: {BG_MID}; width: 8px; border-radius: 4px; }} -QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 4px; min-height: 20px; }} -QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} -QScrollBar:horizontal {{ background: {BG_MID}; height: 8px; border-radius: 4px; }} -QScrollBar::handle:horizontal {{ background: {BORDER}; border-radius: 4px; min-width: 20px; }} -QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} -""" +PG_BG = (28, 30, 38) +PG_PLOT_BG = (45, 48, 64) +PG_ACCENT = (124, 156, 245) +PG_GRID = (58, 61, 80) +PG_TEXT = (138, 143, 168) +ICON_SIZE = 20 # ── pyqtgraph global config ──────────────────────────────────────────────── pg.setConfigOptions( background=pg.mkColor(*PG_BG), foreground=pg.mkColor(*PG_TEXT), antialias=True, - useOpenGL=False, # safer default; set True for large datasets if GPU available + useOpenGL=False, # safer default; set True for large datasets if GPU available ) @@ -139,19 +67,26 @@ def _styled_plot_widget(**kwargs) -> pg.PlotWidget: # ── HDF5 Tree helpers ────────────────────────────────────────────────────── TYPE_ICONS = {"group": "📂", "dataset": "📊"} + def dtype_tag(ds): d = ds.dtype - if np.issubdtype(d, np.integer): return "int" - if np.issubdtype(d, np.floating): return "float" - if np.issubdtype(d, np.complexfloating): return "complex" - if d.kind in ("S", "U", "O"): return "str" + if np.issubdtype(d, np.integer): + return "int" + if np.issubdtype(d, np.floating): + return "float" + if np.issubdtype(d, np.complexfloating): + return "complex" + if d.kind in ("S", "U", "O"): + return "str" return str(d) def populate_tree(parent_item, h5_obj): - style = QApplication.style() - folder_icon = style.standardIcon(QStyle.SP_DirIcon) - file_icon = style.standardIcon(QStyle.SP_FileIcon) + folder_icon = material_icon("folder", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") + vector_icon = material_icon("show_chart", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") + array_icon = material_icon("stacked_line_chart", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") + scalar_icon = material_icon("point_scan", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") + str_icon = material_icon("text_snippet", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") if not isinstance(h5_obj, h5py.Group): return @@ -174,10 +109,22 @@ def populate_tree(parent_item, h5_obj): populate_tree(item, child) elif isinstance(child, h5py.Dataset): - shape_str = "×".join(str(s) for s in child.shape) or "scalar" + shape_str = "x".join(str(s) for s in child.shape) or "scalar" - item = QTreeWidgetItem(parent_item, [key, dtype_tag(child), shape_str]) - item.setIcon(0, file_icon) + dtype = dtype_tag(child) + + if shape_str == "scalar": + if dtype == "str": + icon = str_icon + else: + icon = scalar_icon + elif "x" in shape_str: + icon = array_icon + else: + icon = vector_icon + + item = QTreeWidgetItem(parent_item, [key, dtype, shape_str]) + item.setIcon(0, icon) item.setData(0, Qt.UserRole, child.name) item.setData(0, Qt.UserRole + 1, "dataset") @@ -191,8 +138,6 @@ class DataPanel(QWidget): def __init__(self): super().__init__() self._layout = QVBoxLayout(self) - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.setSpacing(8) # Header hdr = QHBoxLayout() @@ -208,8 +153,8 @@ class DataPanel(QWidget): mode_box = QGroupBox("View mode") mode_layout = QHBoxLayout(mode_box) mode_layout.setContentsMargins(8, 4, 8, 4) - self.rb_auto = QRadioButton("Auto") - self.rb_plot = QRadioButton("Plot") + self.rb_auto = QRadioButton("Auto") + self.rb_plot = QRadioButton("Plot") self.rb_image = QRadioButton("Image") self.rb_table = QRadioButton("Table") self.rb_auto.setChecked(True) @@ -247,9 +192,12 @@ class DataPanel(QWidget): self.display(self._current_data, self.path_label.text()) def _active_mode(self): - if self.rb_plot.isChecked(): return "plot" - if self.rb_image.isChecked(): return "image" - if self.rb_table.isChecked(): return "table" + if self.rb_plot.isChecked(): + return "plot" + if self.rb_image.isChecked(): + return "image" + if self.rb_table.isChecked(): + return "table" return "auto" # ── public ──────────────────────────────────────────────────────────── @@ -273,7 +221,9 @@ class DataPanel(QWidget): else: mode = "table" - if mode == "plot": + if ( + mode == "plot" + ): # TODO disable plot and image if data is str, maybe completely disable manual mode and only allow image/plot for arrays self._show_plot_1d(data) elif mode == "image": self._show_image_2d(data) @@ -283,25 +233,42 @@ class DataPanel(QWidget): # ── 1-D line plot ────────────────────────────────────────────────────── def _show_plot_1d(self, data): self._clear_stack() - flat = data.flatten().astype(float) + + flat = data.reshape(-1).astype( + np.float32 + ) # TODO: Works, but I would prefer 1d plot + row selection like h5web in vscode does + x = np.arange(flat.size, dtype=np.float32) pw = _styled_plot_widget() pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - # Filled area under curve - x = np.arange(len(flat), dtype=float) - fill = pg.FillBetweenItem( - pg.PlotDataItem(x, flat), - pg.PlotDataItem(x, np.zeros_like(flat)), - brush=pg.mkBrush(124, 156, 245, 35), - ) - pw.addItem(fill) - pw.plot(x, flat, - pen=pg.mkPen(color=PG_ACCENT, width=1.6), - antialias=True) + plot_item = pw.getPlotItem() - pw.getPlotItem().setLabel("bottom", "index") - pw.getPlotItem().setLabel("left", "value") + # Prevent intermediate auto-range recalculations during setup + plot_item.setAutoVisible(y=False) + + curve = pg.PlotDataItem( + x, + flat, + pen=pg.mkPen(color=PG_ACCENT, width=1.6), + antialias=False, # critical for 10M points + ) + + pw.addItem(curve) + + # ---- performance optimizations ---- + curve.setDownsampling(auto=True, method="peak") + curve.setClipToView(True) + + # optional but often helpful for very large datasets + curve.setSkipFiniteCheck(True) + + # ---- labels ---- + plot_item.setLabel("bottom", "index") + plot_item.setLabel("left", "value") + + # enable autorange only after item is fully added + plot_item.enableAutoRange() self.stack_layout.addWidget(pw) @@ -318,7 +285,7 @@ class DataPanel(QWidget): # ImageView gives us colorbar + histogram + zoom for free iv = pg.ImageView() - iv.setBackground(pg.mkColor(*PG_BG)) + iv.getView().setBackgroundColor(pg.mkColor(*PG_BG)) iv.ui.histogram.setBackground(pg.mkColor(*PG_PLOT_BG)) iv.ui.roiBtn.hide() iv.ui.menuBtn.hide() @@ -365,13 +332,18 @@ class DataPanel(QWidget): table.verticalHeader().setDefaultSectionSize(22) table.setFont(QFont("JetBrains Mono, Consolas, monospace", 10)) - is_float = np.issubdtype(flat.dtype, np.floating) + is_float = np.issubdtype(flat.dtype, np.floating) is_complex = np.iscomplexobj(flat) + is_bytes = flat.dtype.kind == "S" for r in range(show_rows): for c in range(show_cols): val = flat[r, c] - txt = f"{val:.6g}" if is_float else (f"{val:.4g}" if is_complex else str(val)) + txt = ( + f"{val:.6g}" + if is_float + else f"{val:.4g}" if is_complex else str(val.decode()) if is_bytes else str(val) + ) cell = QTableWidgetItem(txt) cell.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) table.setItem(r, c, cell) @@ -379,75 +351,32 @@ class DataPanel(QWidget): self.stack_layout.addWidget(table) -# ── Attributes panel ─────────────────────────────────────────────────────── -class AttrsPanel(QWidget): - def __init__(self): - super().__init__() - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - title = QLabel("Attributes") - title.setObjectName("section_title") - layout.addWidget(title) - self.table = QTableWidget(0, 2) - self.table.setHorizontalHeaderLabels(["Key", "Value"]) - self.table.horizontalHeader().setStretchLastSection(True) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.table.verticalHeader().hide() - self.table.verticalHeader().setDefaultSectionSize(22) - layout.addWidget(self.table) - - def set_attrs(self, attrs_dict): - self.table.setRowCount(0) - for key, val in attrs_dict.items(): - row = self.table.rowCount() - self.table.insertRow(row) - self.table.setItem(row, 0, QTableWidgetItem(str(key))) - self.table.setItem(row, 1, QTableWidgetItem(str(val))) - - # ── Main window ──────────────────────────────────────────────────────────── class HDF5Viewer(QMainWindow): def __init__(self, filepath=None): super().__init__() self.filepath = filepath - self.h5file = None - # self.setWindowTitle(f"HDF5 Viewer — {os.path.basename(filepath)}") - # self.resize(1280, 800) + self.h5file = None self._build_ui() def load_file(self, f): self.filepath = f - self.h5file = h5py.File(f, "r") - self._load_tree() + self.h5file = h5py.File(f, "r") + filename = f.split("/")[-1] + self._load_tree(filename) def _build_ui(self): central = QWidget() self.setCentralWidget(central) - root_layout = QVBoxLayout(central) - root_layout.setContentsMargins(10, 10, 10, 6) - root_layout.setSpacing(6) - # Top bar - top_bar = QHBoxLayout() - # file_label = QLabel(f"📁 {self.filepath}") - # file_label.setObjectName("path_label") - # top_bar.addWidget(file_label) - top_bar.addStretch() - root_layout.addLayout(top_bar) + root_layout = QHBoxLayout(central) - # Main horizontal splitter - h_split = QSplitter(Qt.Horizontal) - h_split.setHandleWidth(2) + splitter = QSplitter(Qt.Horizontal) + splitter.setChildrenCollapsible(False) - # ── Left: tree + attrs ── - left_pane = QWidget() + # ── Left pane ── + left_pane = QWidget() left_layout = QVBoxLayout(left_pane) - left_layout.setContentsMargins(0, 0, 0, 0) - left_layout.setSpacing(6) - - tree_title = QLabel("Structure") - tree_title.setObjectName("section_title") - left_layout.addWidget(tree_title) self.tree = QTreeWidget() self.tree.setHeaderLabels(["Name", "Type", "Shape"]) @@ -455,36 +384,31 @@ class HDF5Viewer(QMainWindow): self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch) self.tree.header().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.tree.header().setSectionResizeMode(2, QHeaderView.ResizeToContents) - self.tree.setIndentation(16) self.tree.itemClicked.connect(self._on_item_clicked) - left_layout.addWidget(self.tree, 3) - self.attrs_panel = AttrsPanel() - left_layout.addWidget(self.attrs_panel, 1) + left_layout.addWidget(self.tree, 1) - h_split.addWidget(left_pane) - - # ── Right: data panel ── + # ── Right pane ── self.data_panel = DataPanel() - h_split.addWidget(self.data_panel) - h_split.setStretchFactor(0, 1) - h_split.setStretchFactor(1, 3) - h_split.setSizes([320, 960]) + splitter.addWidget(left_pane) + splitter.addWidget(self.data_panel) - root_layout.addWidget(h_split, 1) + # Initial proportions: 25% / 75% + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 3) + splitter.setSizes([300, 900]) + splitter.setHandleWidth(6) + splitter.setChildrenCollapsible(False) - # self.status = QStatusBar() - # self.setStatusBar(self.status) - # self.status.showMessage(f"Loaded: {self.filepath}") + root_layout.addWidget(splitter) - def _load_tree(self): + def _load_tree(self, filename): self.tree.clear() - style = QApplication.style() - folder_icon = style.standardIcon(QStyle.SP_DirIcon) # TODO Choose different icon for head - root_item = QTreeWidgetItem(self.tree, ["/", "Group", ""]) - root_item.setIcon(0, folder_icon) + dataset_icon = material_icon("dataset", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") + root_item = QTreeWidgetItem(self.tree, [filename, "Group", ""]) + root_item.setIcon(0, dataset_icon) root_item.setData(0, Qt.UserRole, "/") root_item.setData(0, Qt.UserRole + 1, "group") root_item.setForeground(0, QColor(ACCENT)) @@ -506,46 +430,14 @@ class HDF5Viewer(QMainWindow): return obj = self.h5file[path] if path != "/" else self.h5file - attrs = dict(obj.attrs) if obj.attrs else {} - self.attrs_panel.set_attrs(attrs) if kind == "dataset": - ds = self.h5file[path] + ds = self.h5file[path] data = ds[()] self.data_panel.display(data, path) - # self.status.showMessage( - # f"{path} | shape {ds.shape} | dtype {ds.dtype}" - # ) else: n = len(obj) if isinstance(obj, h5py.Group) else 0 - # self.status.showMessage( - # f"{path} | Group | {n} items | {len(attrs)} attributes" - # ) def closeEvent(self, event): self.h5file.close() super().closeEvent(event) - - -# ── Entry point ──────────────────────────────────────────────────────────── -def main(): - if len(sys.argv) < 2: - print("Usage: python hdf5_viewer.py ") - sys.exit(1) - - filepath = sys.argv[1] - if not os.path.isfile(filepath): - print(f"Error: file not found — {filepath}") - sys.exit(1) - - app = QApplication(sys.argv) - app.setApplicationName("HDF5 Viewer") - app.setStyleSheet(STYLESHEET) - - window = HDF5Viewer(filepath) - window.show() - sys.exit(app.exec_()) - - -if __name__ == "__main__": - main() \ No newline at end of file -- 2.54.0 From 9506df600d22dbe36fb5ca2491f0ee84a3aaa89f Mon Sep 17 00:00:00 2001 From: x01da Date: Sat, 13 Jun 2026 21:59:30 +0200 Subject: [PATCH 4/5] wip --- .../widgets/data_viewer/data_viewer.py | 36 ++++++-- .../widgets/data_viewer/qt_widgets.py | 89 +++++++++++-------- .../bec_widgets/widgets/data_viewer/viewer.py | 56 ++++++++---- 3 files changed, 118 insertions(+), 63 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py index 9ed8458..16c6826 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py @@ -67,6 +67,11 @@ class DataViewer(BECWidget, QWidget): self.current_row = 0 + logger.info(self.client.acl) + logger.info(self.client.active_account) + logger.info(self.client.proc) + logger.info(self.client.username) + self.input.scan_sel.currentItemChanged_connect(self.scan_sel_changed) self.input.load_button.clicked_connect(self.load_dataset) @@ -75,11 +80,13 @@ class DataViewer(BECWidget, QWidget): self.current_row = kwargs["value"]().row() @SafeSlot() - def load_dataset(self, *_): + def load_dataset( + self, *_ + ): # TODO: Check scan file components for combined xas/xrd scans. Is the Pilatus file in there as well? scan = self.history[self.current_row] file = scan["file_components"][0] + b"_master." + scan["file_components"][1] logger.info(file.decode()) - self.viewer.load_file(file.decode()) + self.viewer.load_files([file.decode()]) @SafeSlot() def on_history_update(self, *_): @@ -89,19 +96,32 @@ class DataViewer(BECWidget, QWidget): # and no scan has finished yet. Limit the history to the latest 20 scans. max_scans = min(len(self.client.history), 20) for n in range(1, max_scans): # last scans, up to 20 + logger.info(self.client.history[-n].metadata) scan_data = self.client.history[-n].metadata["bec"] # logger.info(scan_data) + scan_number = scan_data["scan_number"] + scan_name = scan_data["scan_name"] + comment = scan_data["metadata"]["user_metadata"]["comment"] + sample_name = scan_data["metadata"]["user_metadata"]["sample_name"] + # start = scan_data[] + # end = scan_data[] self.history.append( { - "scan_number": scan_data["scan_number"], - "scan_name": scan_data["scan_name"], - "user_metadata": scan_data["user_metadata"], + "scan_number": scan_number, + "scan_name": scan_name, + "comment": comment, + "sample_name": sample_name, "file_components": scan_data["file_components"], } ) - self.input.scan_sel.addTaggedItem( - label=str(scan_data["scan_number"]), tags=[(scan_data["scan_name"], "#4A90D9")] - ) + tags = [] + tags.append((scan_name, "#4A90D9")) + if sample_name != "": + tags.append((sample_name, "#4A10D9")) + if comment != "": + tags.append((comment, "#FA10D9")) + self.input.scan_sel.addTaggedItem(label=str(scan_number), tags=tags) + # logger.info(f"Scan history: {self.history}") class InputPanel(QWidget): diff --git a/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py b/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py index fa9235c..572a972 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py @@ -4,46 +4,50 @@ Selection works natively; no popup, no paintEvent hacks. """ import sys + +from qtpy.QtCore import QPoint, QRect, QSize, Qt +from qtpy.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen from qtpy.QtWidgets import ( - QApplication, QListWidget, QListWidgetItem, QStyledItemDelegate, - QStyleOptionViewItem, QWidget, QVBoxLayout, QLabel, QStyle, + QApplication, + QLabel, + QListWidget, + QListWidgetItem, + QStyle, + QStyledItemDelegate, + QStyleOptionViewItem, + QVBoxLayout, + QWidget, ) -from qtpy.QtGui import QPainter, QColor, QFont, QPen, QFontMetrics -from qtpy.QtCore import Qt, QRect, QSize, QPoint # ── Design tokens ────────────────────────────────────────────────────────────── -ITEM_HEIGHT = 40 -H_PAD = 12 -TAG_H_PAD = 7 -TAG_V_PAD = 3 -TAG_GAP = 5 -LABEL_TAG_GAP = 12 -CORNER_RADIUS = 4 -FONT_FAMILY = "Segoe UI" -LABEL_FONT_SIZE = 10 -TAG_FONT_SIZE = 8 -TAG_TEXT_COLOR = "#FFFFFF" +ITEM_HEIGHT = 30 +H_PAD = 12 +TAG_H_PAD = 7 +TAG_V_PAD = 3 +TAG_GAP = 5 +LABEL_TAG_GAP = 12 +CORNER_RADIUS = 4 +TAG_TEXT_COLOR = "#FFFFFF" # ── Enum compat (PySide6 nested vs PyQt5 flat) ──────────────────────────────── try: _DEMIBOLD = QFont.Weight.DemiBold - _MEDIUM = QFont.Weight.Medium + _MEDIUM = QFont.Weight.Medium except AttributeError: _DEMIBOLD = QFont.DemiBold - _MEDIUM = QFont.Medium + _MEDIUM = QFont.Medium _AlignVCenter = Qt.AlignmentFlag.AlignVCenter if hasattr(Qt, "AlignmentFlag") else Qt.AlignVCenter -_AlignCenter = Qt.AlignmentFlag.AlignCenter if hasattr(Qt, "AlignmentFlag") else Qt.AlignCenter -_UserRole = Qt.ItemDataRole.UserRole if hasattr(Qt, "ItemDataRole") else Qt.UserRole -_NoPen = Qt.PenStyle.NoPen if hasattr(Qt, "PenStyle") else Qt.NoPen -_AA = (QPainter.RenderHint.Antialiasing - if hasattr(QPainter, "RenderHint") else QPainter.Antialiasing) +_AlignCenter = Qt.AlignmentFlag.AlignCenter if hasattr(Qt, "AlignmentFlag") else Qt.AlignCenter +_UserRole = Qt.ItemDataRole.UserRole if hasattr(Qt, "ItemDataRole") else Qt.UserRole +_NoPen = Qt.PenStyle.NoPen if hasattr(Qt, "PenStyle") else Qt.NoPen +_AA = QPainter.RenderHint.Antialiasing if hasattr(QPainter, "RenderHint") else QPainter.Antialiasing try: - _State_Selected = QStyle.StateFlag.State_Selected + _State_Selected = QStyle.StateFlag.State_Selected _State_MouseOver = QStyle.StateFlag.State_MouseOver except AttributeError: - _State_Selected = QStyle.State_Selected + _State_Selected = QStyle.State_Selected _State_MouseOver = QStyle.State_MouseOver @@ -57,7 +61,7 @@ class TaggedDelegate(QStyledItemDelegate): painter.save() is_selected = bool(option.state & _State_Selected) - is_hover = bool(option.state & _State_MouseOver) + is_hover = bool(option.state & _State_MouseOver) # Background if is_selected: @@ -67,16 +71,23 @@ class TaggedDelegate(QStyledItemDelegate): else: painter.fillRect(option.rect, option.palette.base()) - label_text = index.data(Qt.ItemDataRole.DisplayRole - if hasattr(Qt, "ItemDataRole") else Qt.DisplayRole) or "" + label_text = ( + index.data( + Qt.ItemDataRole.DisplayRole if hasattr(Qt, "ItemDataRole") else Qt.DisplayRole + ) + or "" + ) tags: list = index.data(_UserRole) or [] # Label - label_font = QFont(FONT_FAMILY, LABEL_FONT_SIZE) + label_font = painter.font() # inherit default font label_font.setWeight(_DEMIBOLD) painter.setFont(label_font) - label_color = (option.palette.highlightedText().color() - if is_selected else option.palette.text().color()) + label_color = ( + option.palette.highlightedText().color() + if is_selected + else option.palette.text().color() + ) painter.setPen(QPen(label_color)) fm = QFontMetrics(label_font) @@ -85,7 +96,7 @@ class TaggedDelegate(QStyledItemDelegate): painter.drawText(QPoint(option.rect.left() + H_PAD, label_y), label_text) # Tag pills - tag_font = QFont(FONT_FAMILY, TAG_FONT_SIZE) + tag_font = painter.font() # inherit default font tag_font.setWeight(_MEDIUM) fm_tag = QFontMetrics(tag_font) @@ -122,7 +133,7 @@ class TaggedListWidget(QListWidget): def __init__(self, parent=None) -> None: super().__init__(parent) self.setItemDelegate(TaggedDelegate(self)) - self.setMouseTracking(True) # enables hover highlight + self.setMouseTracking(True) # enables hover highlight def addTaggedItem(self, label: str, tags: list | None = None) -> QListWidgetItem: """ @@ -154,11 +165,11 @@ if __name__ == "__main__": layout.setSpacing(12) lst = TaggedListWidget() - lst.addTaggedItem("NumPy", tags=[("1.26", "#2563EB"), ("stable", "#16A34A")]) - lst.addTaggedItem("Pandas", tags=[("2.2", "#7C3AED"), ("deprecated", "#DC2626")]) - lst.addTaggedItem("PyTorch", tags=[("2.3", "#2563EB"), ("cuda", "#DC2626")]) - lst.addTaggedItem("scikit-learn", tags=[("1.4", "#0891B2"), ("stable", "#16A34A")]) - lst.addTaggedItem("Matplotlib", tags=[("3.8", "#D97706")]) + lst.addTaggedItem("NumPy", tags=[("1.26", "#2563EB"), ("stable", "#16A34A")]) + lst.addTaggedItem("Pandas", tags=[("2.2", "#7C3AED"), ("deprecated", "#DC2626")]) + lst.addTaggedItem("PyTorch", tags=[("2.3", "#2563EB"), ("cuda", "#DC2626")]) + lst.addTaggedItem("scikit-learn", tags=[("1.4", "#0891B2"), ("stable", "#16A34A")]) + lst.addTaggedItem("Matplotlib", tags=[("3.8", "#D97706")]) lst.addTaggedItem("Plain item — no tags") layout.addWidget(lst) @@ -166,7 +177,7 @@ if __name__ == "__main__": def on_selection(): label = lst.currentItem().text() if lst.currentItem() else "" - tags = lst.currentTags() + tags = lst.currentTags() tag_str = " ".join(t for t, _ in tags) if tags else "none" info.setText(f"Selected: {label} tags: {tag_str}") @@ -174,4 +185,4 @@ if __name__ == "__main__": layout.addWidget(info) window.show() - sys.exit(app.exec()) \ No newline at end of file + sys.exit(app.exec()) diff --git a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py index 52f73e3..b975214 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py @@ -355,15 +355,18 @@ class DataPanel(QWidget): class HDF5Viewer(QMainWindow): def __init__(self, filepath=None): super().__init__() - self.filepath = filepath - self.h5file = None + self.h5files = {} # filepath -> h5py.File self._build_ui() + if filepath: + self.load_files([filepath]) - def load_file(self, f): - self.filepath = f - self.h5file = h5py.File(f, "r") - filename = f.split("/")[-1] - self._load_tree(filename) + def load_files(self, filepaths: list[str]): + """Open one or more HDF5 files and add each as a top-level tree node.""" + for f in filepaths: + if f in self.h5files: + continue # already loaded + self.h5files[f] = h5py.File(f, "r") + self._add_file_to_tree(f) def _build_ui(self): central = QWidget() @@ -394,7 +397,6 @@ class HDF5Viewer(QMainWindow): splitter.addWidget(left_pane) splitter.addWidget(self.data_panel) - # Initial proportions: 25% / 75% splitter.setStretchFactor(0, 1) splitter.setStretchFactor(1, 3) splitter.setSizes([300, 900]) @@ -403,17 +405,20 @@ class HDF5Viewer(QMainWindow): root_layout.addWidget(splitter) - def _load_tree(self, filename): - self.tree.clear() + def _add_file_to_tree(self, filepath: str): + """Add a single file as a new top-level node in the tree.""" + h5file = self.h5files[filepath] + filename = filepath.split("/")[-1] dataset_icon = material_icon("dataset", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") root_item = QTreeWidgetItem(self.tree, [filename, "Group", ""]) root_item.setIcon(0, dataset_icon) root_item.setData(0, Qt.UserRole, "/") root_item.setData(0, Qt.UserRole + 1, "group") + root_item.setData(0, Qt.UserRole + 2, filepath) # so clicks know which file root_item.setForeground(0, QColor(ACCENT)) - populate_tree(root_item, self.h5file) + populate_tree(root_item, h5file) self.tree.addTopLevelItem(root_item) # Expand first 2 levels @@ -423,21 +428,40 @@ class HDF5Viewer(QMainWindow): self.tree.setCurrentItem(root_item) + def _get_filepath_for_item(self, item: QTreeWidgetItem) -> str: + """Walk up the tree to find the filepath stored on the root node.""" + node = item + while node.parent(): + node = node.parent() + return node.data(0, Qt.UserRole + 2) + def _on_item_clicked(self, item, _col): path = item.data(0, Qt.UserRole) kind = item.data(0, Qt.UserRole + 1) - if not path: + filepath = self._get_filepath_for_item(item) + + if not path or not filepath: return - obj = self.h5file[path] if path != "/" else self.h5file + h5file = self.h5files[filepath] + obj = h5file[path] if path != "/" else h5file if kind == "dataset": - ds = self.h5file[path] - data = ds[()] + data = h5file[path][()] self.data_panel.display(data, path) else: n = len(obj) if isinstance(obj, h5py.Group) else 0 + def clear_files(self): + """Close all open HDF5 files and reset the tree.""" + for h5file in self.h5files.values(): + h5file.close() + self.h5files.clear() + self.tree.clear() + self.data_panel._show_empty() # TODO: If it works, make function public! + def closeEvent(self, event): - self.h5file.close() + for h5file in self.h5files.values(): + h5file.close() + self.h5files.clear() super().closeEvent(event) -- 2.54.0 From 16fe41dc78bdbd2860a0409e2a82b19f43691d03 Mon Sep 17 00:00:00 2001 From: x01da Date: Sun, 14 Jun 2026 14:42:22 +0200 Subject: [PATCH 5/5] wip --- .../widgets/data_viewer/data_viewer.py | 52 ++++++++++-- .../widgets/data_viewer/qt_widgets.py | 37 --------- .../bec_widgets/widgets/data_viewer/viewer.py | 80 ++++++++++++++----- 3 files changed, 105 insertions(+), 64 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py index 16c6826..9f6ea43 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py @@ -3,6 +3,7 @@ Data Viewer: Custom BEC widget to view data from scans. """ import sys +from datetime import datetime from functools import partial from bec_lib import bec_logger @@ -74,6 +75,7 @@ class DataViewer(BECWidget, QWidget): self.input.scan_sel.currentItemChanged_connect(self.scan_sel_changed) self.input.load_button.clicked_connect(self.load_dataset) + self.input.unload_button.clicked_connect(self.unload_all_datasets) @SafeSlot() def scan_sel_changed(self, *_, **kwargs): @@ -88,6 +90,31 @@ class DataViewer(BECWidget, QWidget): logger.info(file.decode()) self.viewer.load_files([file.decode()]) + @SafeSlot() + def unload_all_datasets(self, *_): + self.viewer.clear_files() + + def duration_string(self, start: str, end: str) -> str: + start_dt = datetime.fromisoformat(start) + end_dt = datetime.fromisoformat(end) + + seconds = abs(int((end_dt - start_dt).total_seconds())) + + days, remainder = divmod(seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + + parts = [] + + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}min") + + return " ".join(parts) if parts else "<1min" + @SafeSlot() def on_history_update(self, *_): self.history = [] @@ -96,15 +123,17 @@ class DataViewer(BECWidget, QWidget): # and no scan has finished yet. Limit the history to the latest 20 scans. max_scans = min(len(self.client.history), 20) for n in range(1, max_scans): # last scans, up to 20 - logger.info(self.client.history[-n].metadata) + # logger.info(self.client.history[-n].metadata["bec"]["status"]) + start_time = self.client.history[-n].metadata["start_time"] + end_time = self.client.history[-n].metadata["end_time"] + logger.info(type(start_time)) scan_data = self.client.history[-n].metadata["bec"] # logger.info(scan_data) scan_number = scan_data["scan_number"] scan_name = scan_data["scan_name"] comment = scan_data["metadata"]["user_metadata"]["comment"] sample_name = scan_data["metadata"]["user_metadata"]["sample_name"] - # start = scan_data[] - # end = scan_data[] + status = scan_data["status"] self.history.append( { "scan_number": scan_number, @@ -112,6 +141,9 @@ class DataViewer(BECWidget, QWidget): "comment": comment, "sample_name": sample_name, "file_components": scan_data["file_components"], + "start_time": start_time, + "end_time": end_time, + "status": status, } ) tags = [] @@ -120,8 +152,15 @@ class DataViewer(BECWidget, QWidget): tags.append((sample_name, "#4A10D9")) if comment != "": tags.append((comment, "#FA10D9")) + if status == "closed": + tags.append((status, "#1F7023")) + elif status == "halted": + tags.append((status, "#A33047")) + else: + tags.append((status, "#656365")) + tags.append((self.duration_string(start_time, end_time), "#656365")) self.input.scan_sel.addTaggedItem(label=str(scan_number), tags=tags) - # logger.info(f"Scan history: {self.history}") + logger.info(f"Scan history: {self.history}") class InputPanel(QWidget): @@ -135,9 +174,12 @@ class InputPanel(QWidget): # Scan selection self.scan_sel = ListWidget("scan_sel", "Scan", ["Si", "Rh", "Pt"]) self.load_button = Button(label_button="Load Dataset", enabled=True) + self.unload_button = Button(label_button="Unload all", enabled=True) # Assemble complete scan selection group - self.input_group = Group("Scan selection", [self.scan_sel, self.load_button]) + self.input_group = Group( + "Scan selection", [self.scan_sel, self.load_button, self.unload_button] + ) self._layout.addWidget(self.input_group) self._layout.addStretch() diff --git a/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py b/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py index 572a972..9d7b4f7 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/qt_widgets.py @@ -149,40 +149,3 @@ class TaggedListWidget(QListWidget): def currentTags(self) -> list: item = self.currentItem() return item.data(_UserRole) or [] if item else [] - - -# ── Demo ────────────────────────────────────────────────────────────────────── -if __name__ == "__main__": - app = QApplication(sys.argv) - app.setStyle("Fusion") - - window = QWidget() - window.setWindowTitle("TaggedListWidget demo") - window.resize(420, 320) - - layout = QVBoxLayout(window) - layout.setContentsMargins(24, 24, 24, 24) - layout.setSpacing(12) - - lst = TaggedListWidget() - lst.addTaggedItem("NumPy", tags=[("1.26", "#2563EB"), ("stable", "#16A34A")]) - lst.addTaggedItem("Pandas", tags=[("2.2", "#7C3AED"), ("deprecated", "#DC2626")]) - lst.addTaggedItem("PyTorch", tags=[("2.3", "#2563EB"), ("cuda", "#DC2626")]) - lst.addTaggedItem("scikit-learn", tags=[("1.4", "#0891B2"), ("stable", "#16A34A")]) - lst.addTaggedItem("Matplotlib", tags=[("3.8", "#D97706")]) - lst.addTaggedItem("Plain item — no tags") - layout.addWidget(lst) - - info = QLabel() - - def on_selection(): - label = lst.currentItem().text() if lst.currentItem() else "" - tags = lst.currentTags() - tag_str = " ".join(t for t, _ in tags) if tags else "none" - info.setText(f"Selected: {label} tags: {tag_str}") - - lst.currentItemChanged.connect(lambda *_: on_selection()) - layout.addWidget(info) - - window.show() - sys.exit(app.exec()) diff --git a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py index b975214..4a99291 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py @@ -18,6 +18,7 @@ from qtpy.QtWidgets import ( QMainWindow, QRadioButton, QSizePolicy, + QSlider, QSplitter, QTableWidget, QTableWidgetItem, @@ -55,7 +56,7 @@ pg.setConfigOptions( def _styled_plot_widget(**kwargs) -> pg.PlotWidget: """Return a PlotWidget already themed to match the dark palette.""" pw = pg.PlotWidget(**kwargs) - pw.setBackground(pg.mkColor(*PG_PLOT_BG)) + # pw.setBackground(pg.mkColor(*PG_PLOT_BG)) ax = pw.getPlotItem() for side in ("left", "bottom", "right", "top"): ax.getAxis(side).setPen(pg.mkPen(color=PG_GRID, width=1)) @@ -65,7 +66,7 @@ def _styled_plot_widget(**kwargs) -> pg.PlotWidget: # ── HDF5 Tree helpers ────────────────────────────────────────────────────── -TYPE_ICONS = {"group": "📂", "dataset": "📊"} +# TYPE_ICONS = {"group": "📂", "dataset": "📊"} def dtype_tag(ds): @@ -234,43 +235,78 @@ class DataPanel(QWidget): def _show_plot_1d(self, data): self._clear_stack() - flat = data.reshape(-1).astype( - np.float32 - ) # TODO: Works, but I would prefer 1d plot + row selection like h5web in vscode does - x = np.arange(flat.size, dtype=np.float32) + is_2d = data.ndim == 2 + + if is_2d: + n_rows, n_cols = data.shape + row_data = data[0].astype(np.float32) + else: + row_data = data.reshape(-1).astype(np.float32) + + x = np.arange(row_data.size, dtype=np.float32) pw = _styled_plot_widget() pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) plot_item = pw.getPlotItem() - - # Prevent intermediate auto-range recalculations during setup plot_item.setAutoVisible(y=False) curve = pg.PlotDataItem( - x, - flat, - pen=pg.mkPen(color=PG_ACCENT, width=1.6), - antialias=False, # critical for 10M points + x, row_data, pen=pg.mkPen(color=PG_ACCENT, width=1.6), antialias=False ) - pw.addItem(curve) - # ---- performance optimizations ---- curve.setDownsampling(auto=True, method="peak") curve.setClipToView(True) - - # optional but often helpful for very large datasets curve.setSkipFiniteCheck(True) - # ---- labels ---- - plot_item.setLabel("bottom", "index") - plot_item.setLabel("left", "value") - - # enable autorange only after item is fully added + # plot_item.setLabel("bottom", "index") + # plot_item.setLabel("left", "value") plot_item.enableAutoRange() - self.stack_layout.addWidget(pw) + if is_2d: + # --- Slider --- + slider = QSlider(Qt.Vertical) + slider.setMinimum(0) + slider.setMaximum(n_rows - 1) + slider.setValue(0) + # slider.setInvertedAppearance(True) # row 0 at top + slider.setFixedWidth(32) + # slider.setSingleStep(1) + slider.setPageStep(1) + + row_label = QLabel("0") + row_label.setAlignment(Qt.AlignCenter) + row_label.setFixedWidth(32) + # row_label.setStyleSheet("color: #aaa; font-size: 10px;") + + def on_row_changed(row): + row_label.setText(str(row)) + new_data = data[row].astype(np.float32) + new_x = np.arange(new_data.size, dtype=np.float32) + curve.setData(new_x, new_data) + plot_item.enableAutoRange() + + slider.valueChanged.connect(on_row_changed) + + slider_col = QWidget() + slider_col.setFixedWidth(36) + col_layout = QVBoxLayout(slider_col) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.setSpacing(2) + col_layout.addWidget(row_label) + col_layout.addWidget(slider) + + container = QWidget() + h_layout = QHBoxLayout(container) + h_layout.setContentsMargins(0, 0, 0, 0) + h_layout.setSpacing(4) + h_layout.addWidget(slider_col) + h_layout.addWidget(pw) + + self.stack_layout.addWidget(container) + else: + self.stack_layout.addWidget(pw) # ── 2-D image ────────────────────────────────────────────────────────── def _show_image_2d(self, data): -- 2.54.0