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",
}