Build Packages / Create release (push) Successful in 23s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 10m6s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 9m6s
Build Packages / build:viewer-tgz:cpu (push) Successful in 11m15s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m21s
Build Packages / build:windows:nocuda (push) Successful in 17m9s
Build Packages / build:windows:cuda (push) Successful in 19m49s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m42s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 16m0s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 14m54s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 17m7s
Build Packages / build:rugnux:windows (push) Successful in 10m47s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 17m4s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 17m8s
Build Packages / Generate python client (push) Successful in 45s
Build Packages / Build documentation (push) Successful in 1m45s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m23s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 19m20s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 18m43s
Build Packages / build:rpm (rocky8) (push) Successful in 19m31s
Build Packages / build:rpm (rocky9) (push) Successful in 20m16s
Build Packages / Unit tests (push) Successful in 1h41m19s
* Fixed a `jfjoch_broker` crash during indexing: sorting no longer misbehaves on non-finite values, and GPU FFT indexer kernel launches are now error-checked. * rugnux needs about a third less peak memory to scale, merge and post-refine rotation data, with identical results. * `rugnux --model`: the placed coordinate file carries the space group its own coordinates obey, and says so when that is not the group the reflection files beside it carry. * `jfjoch_viewer`: fixes in the dataset plots, inspector and layout; spot markers lose their black outline by default (a checkbox under "Image features" restores it) and the highest-pixel markers are white boxes around the pixel. Reviewed-on: #80 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
172 lines
7.1 KiB
C++
172 lines
7.1 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "JFJochViewerFileBrowser.h"
|
|
|
|
#include <QVBoxLayout>
|
|
#include <QHBoxLayout>
|
|
#include <QLineEdit>
|
|
#include <QToolButton>
|
|
#include <QTreeView>
|
|
#include <QHeaderView>
|
|
#include <QFileDialog>
|
|
#include <QSettings>
|
|
#include <QDir>
|
|
#include <QFileInfo>
|
|
#include <QRegularExpression>
|
|
|
|
namespace {
|
|
constexpr auto kDataRootEnvVar = "JUNGFRAUJOCH_DATA_ROOT";
|
|
constexpr auto kSettingsKey = "fileBrowserRoot";
|
|
|
|
// The filter box narrows files only; a directory always passes. Filtering directories too would
|
|
// hide everything below one whose own name does not match - including the files that do match -
|
|
// and would hide the root's ancestors, which leaves the tree with no valid root index at all.
|
|
class FileOnlyFilterProxy : public QSortFilterProxyModel {
|
|
public:
|
|
using QSortFilterProxyModel::QSortFilterProxyModel;
|
|
|
|
protected:
|
|
bool filterAcceptsRow(int row, const QModelIndex &parent) const override {
|
|
const auto *fs = qobject_cast<const QFileSystemModel *>(sourceModel());
|
|
if (fs && fs->isDir(fs->index(row, 0, parent)))
|
|
return true;
|
|
return QSortFilterProxyModel::filterAcceptsRow(row, parent);
|
|
}
|
|
};
|
|
}
|
|
|
|
QString JFJochViewerFileBrowser::SlsDataRoot(const QString &user) {
|
|
const auto match = QRegularExpression("\\Ae(\\d{5})\\z").match(user);
|
|
if (!match.hasMatch())
|
|
return {};
|
|
return "/sls/mx/data/p" + match.captured(1) + "/raw";
|
|
}
|
|
|
|
QString JFJochViewerFileBrowser::DefaultRoot() {
|
|
// Site default (e.g. a beamline launcher setting it to /sls/mx/data/p<group>), then whatever
|
|
// root was last browsed to (so re-launching without the variable set doesn't start over from
|
|
// scratch), then - for an SLS beamline account - that proposal's raw data, then the user's home
|
|
// - never the current directory, which for an installed binary is typically the install
|
|
// location rather than anywhere the user would keep data.
|
|
const QString env_root = qEnvironmentVariable(kDataRootEnvVar);
|
|
if (!env_root.isEmpty() && QDir(env_root).exists())
|
|
return env_root;
|
|
|
|
const QString last_root = QSettings("PSI", "jfjoch_viewer").value(kSettingsKey).toString();
|
|
if (!last_root.isEmpty() && QDir(last_root).exists())
|
|
return last_root;
|
|
|
|
// USER is the portable spelling Qt exposes; it is simply empty where the beamline directory
|
|
// could not exist anyway, so everyone else silently gets home, as before.
|
|
const QString sls_root = SlsDataRoot(qEnvironmentVariable("USER"));
|
|
if (!sls_root.isEmpty() && QDir(sls_root).exists())
|
|
return sls_root;
|
|
|
|
return QDir::homePath();
|
|
}
|
|
|
|
JFJochViewerFileBrowser::JFJochViewerFileBrowser(QWidget *parent) : QWidget(parent) {
|
|
auto *layout = new QVBoxLayout(this);
|
|
// No inset: every other dock panel sits flush against its edges, and 4 px here put the panel's
|
|
// content 4 px away from the resize handle it shares with the image, which reads as a handle
|
|
// that is off-centre.
|
|
layout->setContentsMargins(0, 0, 0, 0);
|
|
|
|
auto *root_row = new QHBoxLayout();
|
|
root_edit_ = new QLineEdit(this);
|
|
root_edit_->setToolTip("Data root - type a path or use Browse…");
|
|
auto *browse_button = new QToolButton(this);
|
|
browse_button->setText("…");
|
|
browse_button->setToolTip("Browse for a data root…");
|
|
root_row->addWidget(root_edit_);
|
|
root_row->addWidget(browse_button);
|
|
layout->addLayout(root_row);
|
|
|
|
filter_edit_ = new QLineEdit(this);
|
|
filter_edit_->setPlaceholderText("Filter…");
|
|
filter_edit_->setClearButtonEnabled(true);
|
|
layout->addWidget(filter_edit_);
|
|
|
|
model_ = new QFileSystemModel(this);
|
|
model_->setFilter(QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot);
|
|
// Directories are always listed regardless of these; only *_master.h5 / *_process.h5 and
|
|
// miniCBF frames among files. The many _data_NNNNNN.h5 shards of a dataset are not meant to be
|
|
// opened directly; a .cbf is one frame per file, and opening any of them opens its whole sweep.
|
|
model_->setNameFilters({"*_master.h5", "*_process.h5", "*.cbf"});
|
|
model_->setNameFilterDisables(false); // hide non-matching files rather than just greying them out
|
|
|
|
proxy_ = new FileOnlyFilterProxy(this);
|
|
proxy_->setSourceModel(model_);
|
|
proxy_->setFilterKeyColumn(0);
|
|
proxy_->setFilterCaseSensitivity(Qt::CaseInsensitive);
|
|
// Deliberately not recursive: it only ever narrows names already listed at an expanded level,
|
|
// never forces fetching a collapsed subtree just to search inside it.
|
|
|
|
tree_ = new QTreeView(this);
|
|
tree_->setModel(proxy_);
|
|
tree_->setHeaderHidden(true);
|
|
for (int col = 1; col < model_->columnCount(); ++col)
|
|
tree_->hideColumn(col);
|
|
tree_->setSortingEnabled(false);
|
|
layout->addWidget(tree_);
|
|
|
|
connect(browse_button, &QToolButton::clicked, this, &JFJochViewerFileBrowser::onBrowseClicked);
|
|
connect(root_edit_, &QLineEdit::editingFinished, this, &JFJochViewerFileBrowser::onRootEditingFinished);
|
|
connect(filter_edit_, &QLineEdit::textChanged, proxy_, &QSortFilterProxyModel::setFilterFixedString);
|
|
connect(tree_, &QTreeView::doubleClicked, this, &JFJochViewerFileBrowser::onDoubleClicked);
|
|
|
|
SetRoot(DefaultRoot());
|
|
}
|
|
|
|
void JFJochViewerFileBrowser::SetRoot(const QString &path) {
|
|
QDir dir(path);
|
|
if (!dir.exists())
|
|
return;
|
|
|
|
const QString canonical = QDir::toNativeSeparators(dir.absolutePath());
|
|
root_edit_->setText(canonical);
|
|
model_->setRootPath(canonical);
|
|
tree_->setRootIndex(proxy_->mapFromSource(model_->index(canonical)));
|
|
|
|
QSettings("PSI", "jfjoch_viewer").setValue(kSettingsKey, canonical);
|
|
}
|
|
|
|
void JFJochViewerFileBrowser::ResetToDefaultRoot() {
|
|
SetRoot(DefaultRoot());
|
|
}
|
|
|
|
void JFJochViewerFileBrowser::FollowPath(const QString &path) {
|
|
const QString absolute = QFileInfo(path).absoluteFilePath();
|
|
// Containment by path components, not by string prefix, so /data/foo2 is not inside /data/foo.
|
|
// relativeFilePath hands back an absolute path when there is no relative route at all (a file on
|
|
// another Windows drive).
|
|
const QString relative = QDir(model_->rootPath()).relativeFilePath(absolute);
|
|
if (QDir::isAbsolutePath(relative) || relative == ".." || relative.startsWith("../"))
|
|
return;
|
|
|
|
const QModelIndex index = proxy_->mapFromSource(model_->index(absolute));
|
|
if (!index.isValid())
|
|
return;
|
|
|
|
tree_->setCurrentIndex(index);
|
|
tree_->scrollTo(index);
|
|
}
|
|
|
|
void JFJochViewerFileBrowser::onBrowseClicked() {
|
|
const QString dir = QFileDialog::getExistingDirectory(this, "Select data root", root_edit_->text());
|
|
if (!dir.isEmpty())
|
|
SetRoot(dir);
|
|
}
|
|
|
|
void JFJochViewerFileBrowser::onRootEditingFinished() {
|
|
SetRoot(root_edit_->text());
|
|
}
|
|
|
|
void JFJochViewerFileBrowser::onDoubleClicked(const QModelIndex &index) {
|
|
const QModelIndex source = proxy_->mapToSource(index);
|
|
if (!source.isValid() || model_->isDir(source))
|
|
return; // directories: let the tree's own expand/collapse handle the double click
|
|
emit openDataset(QDir::toNativeSeparators(model_->filePath(source)), 0, 1, false);
|
|
}
|