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