@@ -138,8 +138,7 @@ class DataViewer(BECWidget, QWidget):
|
||||
|
||||
days, remainder = divmod(seconds, 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, remainder = divmod(remainder, 60)
|
||||
seconds, _ = divmod(remainder, 60)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
parts = []
|
||||
if days:
|
||||
@@ -202,7 +201,7 @@ class DataViewer(BECWidget, QWidget):
|
||||
tags.append((comment, get_accent_colors().warning.name()))
|
||||
if status == "closed":
|
||||
tags.append((status, get_accent_colors().success.name()))
|
||||
elif status == "halted":
|
||||
elif status == "halted" or status == "aborted":
|
||||
tags.append((status, get_accent_colors().emergency.name()))
|
||||
else:
|
||||
tags.append((status, "#656365"))
|
||||
|
||||
@@ -13,7 +13,7 @@ import numpy as np
|
||||
class NodeInfo:
|
||||
"""Describes a single node (group or dataset) inside a loaded file."""
|
||||
|
||||
__slots__ = ("name", "path", "kind", "dtype", "shape", "display_hint")
|
||||
__slots__ = ("name", "path", "kind", "dtype", "shape")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -22,14 +22,12 @@ class NodeInfo:
|
||||
kind: Literal["group", "dataset"],
|
||||
dtype: str = "",
|
||||
shape: tuple[int, ...] = (),
|
||||
display_hint: str = "",
|
||||
):
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.kind = kind
|
||||
self.dtype = dtype # e.g. "float", "int", "str", …
|
||||
self.shape = shape # empty tuple for scalars / groups
|
||||
self.display_hint = display_hint # e.g. "image_native" — passed through to DataPanel
|
||||
|
||||
|
||||
class BaseFileLoader(ABC):
|
||||
@@ -74,11 +72,6 @@ class BaseFileLoader(ABC):
|
||||
return sum(1 for _ in self.iter_nodes(path))
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# HDF5 loader (replaces the inline h5py logic that was in HDF5Viewer)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class HDF5Loader(BaseFileLoader):
|
||||
"""Loader for HDF5 / NeXus files (.h5, .hdf5, .nxs, .nx)."""
|
||||
|
||||
@@ -132,7 +125,7 @@ class HDF5Loader(BaseFileLoader):
|
||||
|
||||
def read_dataset(self, path: str) -> np.ndarray:
|
||||
assert self._file is not None, "File not open"
|
||||
return self._file[path][()], "h5"
|
||||
return self._file[path][()]
|
||||
|
||||
def child_count(self, path: str) -> int:
|
||||
assert self._file is not None, "File not open"
|
||||
@@ -145,9 +138,7 @@ class ImageLoader(BaseFileLoader):
|
||||
Loader for raster image files.
|
||||
|
||||
The file is treated as a single, flat dataset. ``iter_nodes`` yields one
|
||||
leaf node whose ``display_hint`` is set to ``"image_native"`` so that
|
||||
``DataPanel`` knows to render it as a native Qt image rather than pushing
|
||||
it through the pyqtgraph pipeline.
|
||||
leaf node.
|
||||
|
||||
Requires: Pillow (``pip install Pillow``)
|
||||
"""
|
||||
@@ -194,34 +185,22 @@ class ImageLoader(BaseFileLoader):
|
||||
mode = img.mode # e.g. "RGB", "RGBA", "L", …
|
||||
|
||||
name = os.path.basename(self._filepath)
|
||||
yield NodeInfo(
|
||||
name=name,
|
||||
path="/image",
|
||||
kind="dataset",
|
||||
dtype=mode,
|
||||
shape=(h, w),
|
||||
display_hint="image_native",
|
||||
)
|
||||
yield NodeInfo(name=name, path="/image", kind="dataset", dtype=mode, shape=(h, w))
|
||||
|
||||
def read_dataset(self, path: str) -> np.ndarray:
|
||||
"""Return the image as a uint8 numpy array (H × W × C or H × W)."""
|
||||
"""Return the image as a uint8 numpy array (H x W x C or H x W)."""
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
with _PILImage.open(self._filepath) as img: # type: ignore[arg-type]
|
||||
# Animated GIF → first frame only
|
||||
if hasattr(img, "n_frames") and img.n_frames > 1:
|
||||
img.seek(0)
|
||||
return np.asarray(img), "image_native"
|
||||
return np.asarray(img)
|
||||
|
||||
def child_count(self, path: str) -> int:
|
||||
return 1 if path == "/" else 0
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Loader registry
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class LoaderRegistry:
|
||||
"""Maps file extensions to loader classes."""
|
||||
|
||||
@@ -245,6 +224,6 @@ class LoaderRegistry:
|
||||
|
||||
|
||||
# Default global registry — pre-populated with built-in loaders.
|
||||
_registry = LoaderRegistry()
|
||||
_registry.register(HDF5Loader)
|
||||
_registry.register(ImageLoader)
|
||||
registry = LoaderRegistry()
|
||||
registry.register(HDF5Loader)
|
||||
registry.register(ImageLoader)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Data viewer
|
||||
Data viewer displaying the data
|
||||
"""
|
||||
|
||||
from typing import Literal, Optional
|
||||
@@ -32,23 +32,24 @@ from qtpy.QtWidgets import (
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MAX_ROWS = 2000
|
||||
MAX_COLS = 500
|
||||
|
||||
|
||||
class DataView(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._layout = QVBoxLayout(self)
|
||||
|
||||
# Header
|
||||
hdr = QHBoxLayout()
|
||||
self.path_label = QLabel("Select a dataset from the tree")
|
||||
header = QHBoxLayout()
|
||||
self.path_label = QLabel("")
|
||||
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)
|
||||
header.addWidget(self.path_label, 1)
|
||||
header.addWidget(self.info_label)
|
||||
self._layout.addLayout(header)
|
||||
|
||||
# View-mode selector
|
||||
mode_box = QGroupBox("View mode")
|
||||
mode_layout = QHBoxLayout(mode_box)
|
||||
mode_layout.setContentsMargins(8, 4, 8, 4)
|
||||
@@ -57,39 +58,71 @@ class DataView(QWidget):
|
||||
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):
|
||||
for rb in (self.rb_auto, self.rb_image, self.rb_plot, 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.content = QWidget()
|
||||
self.content_layout = QVBoxLayout(self.content)
|
||||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.addWidget(self.content, 1)
|
||||
|
||||
self.plot_widget = None
|
||||
self.image_widget = None
|
||||
|
||||
self._current_data = None
|
||||
self.show_empty()
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────
|
||||
def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None):
|
||||
"""
|
||||
Apply the theme
|
||||
|
||||
Args:
|
||||
theme (Optional[str]): Theme, either "dark", "light", or None. Defaults to None.
|
||||
"""
|
||||
if theme is None:
|
||||
app = QApplication.instance()
|
||||
theme = app.theme.theme # type: ignore
|
||||
|
||||
bg_color = pg.getConfigOption("background")
|
||||
fg_color = pg.getConfigOption("foreground")
|
||||
if self.plot_widget is not None:
|
||||
n_curves = len(self.plot_widget.listDataItems())
|
||||
colors = Colors.golden_angle_color(
|
||||
colormap="plasma", num=max(10, n_curves + 1), format="HEX"
|
||||
)
|
||||
for idx, curve in enumerate(self.plot_widget.listDataItems()):
|
||||
curve.setPen(pg.mkPen(color=colors[idx]))
|
||||
# Background
|
||||
self.plot_widget.setBackground(bg_color)
|
||||
# Axes (tick marks, tick labels, axis line)
|
||||
for axis in ["left", "bottom", "right", "top"]:
|
||||
ax = self.plot_widget.getAxis(axis)
|
||||
ax.setPen(pg.mkPen(color=fg_color))
|
||||
ax.setTextPen(pg.mkPen(color=fg_color))
|
||||
|
||||
if self.image_widget is not None:
|
||||
self.image_widget.getView().setBackgroundColor(bg_color)
|
||||
self.image_widget.ui.histogram.setBackground(bg_color)
|
||||
|
||||
def _clear_stack(self):
|
||||
while self.stack_layout.count():
|
||||
item = self.stack_layout.takeAt(0)
|
||||
while self.content_layout.count():
|
||||
item = self.content_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
self.plot_widget = None
|
||||
self.image_widget = None
|
||||
|
||||
def show_empty(self):
|
||||
"""Empties the content area."""
|
||||
self._clear_stack()
|
||||
lbl = QLabel("No data selected")
|
||||
lbl.setObjectName("info_label")
|
||||
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.stack_layout.addWidget(lbl)
|
||||
empty_label = QLabel("No data selected")
|
||||
empty_label.setObjectName("info_label")
|
||||
empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.path_label.setText("")
|
||||
self.info_label.setText("")
|
||||
self.content_layout.addWidget(empty_label)
|
||||
|
||||
def show_unsupported(self, path: str = "") -> None:
|
||||
"""Display a friendly 'not implemented' message for unknown file types."""
|
||||
@@ -100,7 +133,7 @@ class DataView(QWidget):
|
||||
lbl = QLabel("File type not supported")
|
||||
lbl.setObjectName("info_label")
|
||||
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.stack_layout.addWidget(lbl)
|
||||
self.content_layout.addWidget(lbl)
|
||||
|
||||
def _on_mode_change(self):
|
||||
if self._current_data is not None:
|
||||
@@ -115,8 +148,7 @@ class DataView(QWidget):
|
||||
return "table"
|
||||
return "auto"
|
||||
|
||||
# ── public ────────────────────────────────────────────────────────────
|
||||
def display(self, data, path: str = "", display_hint: str = "") -> None:
|
||||
def display(self, data, path: str = "") -> None:
|
||||
"""
|
||||
Render *data* in the panel.
|
||||
|
||||
@@ -126,10 +158,6 @@ class DataView(QWidget):
|
||||
A ``numpy.ndarray`` (or anything convertible to one).
|
||||
path:
|
||||
Human-readable label shown in the header.
|
||||
display_hint:
|
||||
Optional hint from the loader. ``"image_native"`` bypasses the
|
||||
pyqtgraph pipeline and renders via a Qt ``QLabel``/``QPixmap``
|
||||
instead, which correctly handles RGB/RGBA colour images.
|
||||
"""
|
||||
self._current_data = data
|
||||
self.path_label.setText(path)
|
||||
@@ -137,15 +165,13 @@ class DataView(QWidget):
|
||||
if not isinstance(data, np.ndarray):
|
||||
data = np.array(data)
|
||||
|
||||
self.info_label.setText(
|
||||
f"shape {data.shape} · dtype {data.dtype} · {data.size:,} elements"
|
||||
)
|
||||
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 <= 4 and min(data.shape) > 1:
|
||||
elif data.ndim <= 4 and min(data.shape, default=0) > 1:
|
||||
mode = "image"
|
||||
else:
|
||||
mode = "table"
|
||||
@@ -157,76 +183,13 @@ class DataView(QWidget):
|
||||
else:
|
||||
self._show_table(data)
|
||||
|
||||
# ── Native raster image (RGB / RGBA / greyscale) ───────────────────────
|
||||
def _show_native_image(self, data: np.ndarray) -> None:
|
||||
"""
|
||||
Render a uint8 numpy array (H×W, H×W×3, or H×W×4) using a plain
|
||||
Qt QLabel so that colour images display correctly without pyqtgraph's
|
||||
colourmap pipeline. The image is scaled to fit the available space
|
||||
while preserving the aspect ratio.
|
||||
"""
|
||||
from qtpy.QtGui import QImage
|
||||
|
||||
self._clear_stack()
|
||||
|
||||
arr = data
|
||||
if arr.dtype != np.uint8:
|
||||
# Normalise to 0–255 for display
|
||||
lo, hi = arr.min(), arr.max()
|
||||
arr = ((arr - lo) / max(hi - lo, 1) * 255).astype(np.uint8)
|
||||
|
||||
if arr.ndim == 2:
|
||||
# Greyscale → replicate to RGB so QImage is straightforward
|
||||
arr = np.stack([arr, arr, arr], axis=-1)
|
||||
|
||||
if arr.ndim == 3 and arr.shape[2] == 4:
|
||||
fmt = QImage.Format.Format_RGBA8888
|
||||
else:
|
||||
if arr.shape[2] != 3:
|
||||
arr = arr[:, :, :3]
|
||||
fmt = QImage.Format.Format_RGB888
|
||||
|
||||
h, w, ch = arr.shape
|
||||
bytes_per_line = ch * w
|
||||
arr_contiguous = np.ascontiguousarray(arr)
|
||||
qimg = QImage(arr_contiguous.data, w, h, bytes_per_line, fmt)
|
||||
pixmap = QPixmap.fromImage(qimg)
|
||||
|
||||
label = QLabel()
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
label.setMinimumSize(1, 1)
|
||||
label.setPixmap(
|
||||
pixmap.scaled(
|
||||
label.size(),
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
|
||||
# Re-scale when the panel is resized
|
||||
def _on_resize(event, _lbl=label, _px=pixmap):
|
||||
_lbl.setPixmap(
|
||||
_px.scaled(
|
||||
_lbl.size(),
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
super(QLabel, _lbl).resizeEvent(event) # type: ignore[arg-type]
|
||||
|
||||
label.resizeEvent = _on_resize # type: ignore[method-assign]
|
||||
|
||||
self.stack_layout.addWidget(label)
|
||||
|
||||
# ── 1-D line plot ──────────────────────────────────────────────────────
|
||||
def _show_plot_1d(self, data):
|
||||
self._clear_stack()
|
||||
|
||||
is_2d = data.ndim == 2
|
||||
|
||||
if is_2d:
|
||||
n_rows, n_cols = data.shape
|
||||
n_rows, _ = data.shape
|
||||
row_data = data[0].astype(np.float32)
|
||||
else:
|
||||
row_data = data.reshape(-1).astype(np.float32)
|
||||
@@ -253,7 +216,6 @@ class DataView(QWidget):
|
||||
plot_item.enableAutoRange() # type: ignore[attr-defined]
|
||||
|
||||
if is_2d:
|
||||
# --- Slider ---
|
||||
slider = QSlider(Qt.Orientation.Vertical)
|
||||
slider.setMinimum(0)
|
||||
slider.setMaximum(n_rows - 1)
|
||||
@@ -294,45 +256,12 @@ class DataView(QWidget):
|
||||
h_layout.addWidget(slider_col)
|
||||
h_layout.addWidget(self.plot_widget)
|
||||
|
||||
self.stack_layout.addWidget(container)
|
||||
self.content_layout.addWidget(container)
|
||||
else:
|
||||
self.stack_layout.addWidget(self.plot_widget)
|
||||
self.content_layout.addWidget(self.plot_widget)
|
||||
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None):
|
||||
"""
|
||||
Apply the theme
|
||||
|
||||
Args:
|
||||
theme (Optional[str]): Theme, either "dark", "light", or None. Defaults to None.
|
||||
"""
|
||||
if theme is None:
|
||||
app = QApplication.instance()
|
||||
theme = app.theme.theme # type: ignore
|
||||
|
||||
bg_color = pg.getConfigOption("background")
|
||||
fg_color = pg.getConfigOption("foreground")
|
||||
if self.plot_widget is not None:
|
||||
n_curves = len(self.plot_widget.listDataItems())
|
||||
colors = Colors.golden_angle_color(
|
||||
colormap="plasma", num=max(10, n_curves + 1), format="HEX"
|
||||
)
|
||||
for idx, curve in enumerate(self.plot_widget.listDataItems()):
|
||||
curve.setPen(pg.mkPen(color=colors[idx]))
|
||||
# Background
|
||||
self.plot_widget.setBackground(bg_color)
|
||||
# Axes (tick marks, tick labels, axis line)
|
||||
for axis in ["left", "bottom", "right", "top"]:
|
||||
ax = self.plot_widget.getAxis(axis)
|
||||
ax.setPen(pg.mkPen(color=fg_color))
|
||||
ax.setTextPen(pg.mkPen(color=fg_color))
|
||||
|
||||
if self.image_widget is not None:
|
||||
self.image_widget.getView().setBackgroundColor(bg_color)
|
||||
self.image_widget.ui.histogram.setBackground(bg_color)
|
||||
|
||||
# ── 2-D image ──────────────────────────────────────────────────────────
|
||||
def _show_image_2d(self, data):
|
||||
self._clear_stack()
|
||||
|
||||
@@ -372,11 +301,9 @@ class DataView(QWidget):
|
||||
)
|
||||
|
||||
set_image(img)
|
||||
|
||||
self.image_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
if stacked:
|
||||
# --- Slider ---
|
||||
slider = QSlider(Qt.Orientation.Vertical)
|
||||
slider.setMinimum(0)
|
||||
slider.setMaximum(n_images - 1)
|
||||
@@ -414,18 +341,15 @@ class DataView(QWidget):
|
||||
h_layout.addWidget(slider_col)
|
||||
h_layout.addWidget(self.image_widget)
|
||||
|
||||
self.stack_layout.addWidget(container)
|
||||
self.content_layout.addWidget(container)
|
||||
else:
|
||||
self.stack_layout.addWidget(self.image_widget)
|
||||
self.content_layout.addWidget(self.image_widget)
|
||||
|
||||
self.apply_theme()
|
||||
|
||||
# ── 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:
|
||||
@@ -440,18 +364,15 @@ class DataView(QWidget):
|
||||
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 = QLabel(f"⚠ Showing {show_rows}/{rows} rows x {show_cols}/{cols} columns")
|
||||
note.setObjectName("info_label")
|
||||
note.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.stack_layout.addWidget(note)
|
||||
self.content_layout.addWidget(note)
|
||||
|
||||
table = QTableWidget(show_rows, show_cols)
|
||||
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
table.setSelectionMode(QAbstractItemView.SelectionMode.ContiguousSelection)
|
||||
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.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)
|
||||
@@ -469,4 +390,4 @@ class DataView(QWidget):
|
||||
cell.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
table.setItem(r, c, cell)
|
||||
|
||||
self.stack_layout.addWidget(table)
|
||||
self.content_layout.addWidget(table)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""
|
||||
File Viewer — qtpy + pyqtgraph
|
||||
Supports pluggable file-format loaders (HDF5 built-in; extend via BaseFileLoader).
|
||||
Scan viewer. Displays files of one or more scans in a tree view
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal, Optional
|
||||
|
||||
@@ -28,7 +25,9 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..loaders import BaseFileLoader, LoaderRegistry, _registry
|
||||
# pylint: disable=E0402
|
||||
from ..loaders import BaseFileLoader, registry
|
||||
from ..widgets.qt_widgets import Group
|
||||
from .data_view import DataView
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -38,25 +37,54 @@ ICON_SIZE = 20
|
||||
|
||||
class ScanViewer(QMainWindow):
|
||||
"""
|
||||
Generic file viewer. Supports any format registered in *registry*.
|
||||
Generic scan viewer. Supports any format registered in *registry*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filepath:
|
||||
Optional path to open on startup.
|
||||
registry:
|
||||
``LoaderRegistry`` to use. Defaults to the module-level ``_registry``
|
||||
which ships with ``HDF5Loader`` pre-registered.
|
||||
Args:
|
||||
filepath(str): Optional path to open on startup.
|
||||
"""
|
||||
|
||||
def __init__(self, filepath: Optional[str] = None, registry: Optional[LoaderRegistry] = None):
|
||||
def __init__(self, filepath: Optional[str] = None):
|
||||
super().__init__()
|
||||
self._registry = registry or _registry
|
||||
self.registry = registry
|
||||
|
||||
# filepath -> (loader_instance, display_name)
|
||||
self._open_files: dict[str, tuple[BaseFileLoader, str]] = {}
|
||||
|
||||
self._build_ui()
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root_layout = QHBoxLayout(central)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
|
||||
left_pane = QWidget()
|
||||
left_layout = QVBoxLayout(left_pane)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setMinimumWidth(250)
|
||||
self.tree.setHeaderLabels(["Name", "Type", "Shape"])
|
||||
self.tree.header().setStretchLastSection(False)
|
||||
self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
self.tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.tree.itemClicked.connect(self._on_item_clicked)
|
||||
|
||||
left_layout.addWidget(self.tree, 1)
|
||||
|
||||
self.data_panel = DataView()
|
||||
|
||||
splitter.addWidget(left_pane)
|
||||
splitter.addWidget(self.data_panel)
|
||||
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 3)
|
||||
splitter.setSizes([300, 900])
|
||||
splitter.setHandleWidth(6)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
|
||||
self.scan_view_group = Group("Scan view", [splitter])
|
||||
|
||||
root_layout.addWidget(self.scan_view_group)
|
||||
|
||||
if filepath:
|
||||
self.load_files([filepath])
|
||||
|
||||
@@ -69,17 +97,15 @@ class ScanViewer(QMainWindow):
|
||||
"""
|
||||
self.data_panel.apply_theme(theme)
|
||||
|
||||
# ── public API ────────────────────────────────────────────────────────
|
||||
|
||||
def load_files(self, filepaths: list[str]) -> None:
|
||||
"""Open one or more files and add each as a top-level tree node."""
|
||||
for fp in filepaths:
|
||||
if fp in self._open_files:
|
||||
continue # already loaded
|
||||
|
||||
loader = self._registry.get_loader(fp)
|
||||
loader = self.registry.get_loader(fp)
|
||||
if loader is None:
|
||||
supported = ", ".join(self._registry.supported_extensions)
|
||||
supported = ", ".join(self.registry.supported_extensions)
|
||||
logger.warning("No loader found for %r. Supported extensions: %s", fp, supported)
|
||||
continue
|
||||
|
||||
@@ -102,53 +128,12 @@ class ScanViewer(QMainWindow):
|
||||
self.data_panel.show_empty()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Close all"""
|
||||
for loader, _ in self._open_files.values():
|
||||
loader.close()
|
||||
self._open_files.clear()
|
||||
super().closeEvent(event)
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
|
||||
root_layout = QHBoxLayout(central)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
|
||||
# ── Left pane ──
|
||||
left_pane = QWidget()
|
||||
left_layout = QVBoxLayout(left_pane)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setMinimumWidth(250)
|
||||
self.tree.setHeaderLabels(["Name", "Type", "Shape"])
|
||||
self.tree.header().setStretchLastSection(False)
|
||||
self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
self.tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.tree.itemClicked.connect(self._on_item_clicked)
|
||||
|
||||
left_layout.addWidget(self.tree, 1)
|
||||
|
||||
# ── Right pane ──
|
||||
self.data_panel = DataView()
|
||||
|
||||
splitter.addWidget(left_pane)
|
||||
splitter.addWidget(self.data_panel)
|
||||
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 3)
|
||||
splitter.setSizes([300, 900])
|
||||
splitter.setHandleWidth(6)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
|
||||
root_layout.addWidget(splitter)
|
||||
|
||||
# ── Tree helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _add_file_to_tree(self, filepath: str, loader: BaseFileLoader, display_name: str) -> None:
|
||||
"""Add a single file as a new top-level node in the tree."""
|
||||
dataset_icon = material_icon(
|
||||
@@ -163,7 +148,7 @@ class ScanViewer(QMainWindow):
|
||||
self._populate_tree(root_item, loader, "/")
|
||||
self.tree.addTopLevelItem(root_item)
|
||||
|
||||
# Expand first 2 levels
|
||||
# Expand first 2 levels by default
|
||||
self.tree.expandItem(root_item)
|
||||
for i in range(root_item.childCount()):
|
||||
self.tree.expandItem(root_item.child(i))
|
||||
@@ -206,8 +191,6 @@ class ScanViewer(QMainWindow):
|
||||
item.setForeground(1, QBrush(QColor(get_accent_colors().success.name())))
|
||||
item.setForeground(2, QBrush(QColor("#656365")))
|
||||
|
||||
# ── Event handling ────────────────────────────────────────────────────
|
||||
|
||||
def _get_filepath_for_item(self, item: QTreeWidgetItem) -> str:
|
||||
"""Walk up the tree to find the filepath stored on the root node."""
|
||||
node = item
|
||||
@@ -226,9 +209,5 @@ class ScanViewer(QMainWindow):
|
||||
loader, _ = self._open_files[filepath]
|
||||
|
||||
if kind == "dataset":
|
||||
data, ftype = loader.read_dataset(path)
|
||||
self.data_panel.display(data, path, ftype)
|
||||
else:
|
||||
# Group selected — show child count in status bar (optional)
|
||||
count = loader.child_count(path)
|
||||
self.statusBar().showMessage(f"{path} ({count} items)")
|
||||
data = loader.read_dataset(path)
|
||||
self.data_panel.display(data, path)
|
||||
|
||||
Reference in New Issue
Block a user