Files
Jungfraujoch/viewer/widgets/JFJochViewerFileBrowser.cpp
T
leonarski_fandClaude Opus 5 0080d2f4ba viewer: wrap a long dataset name, centre the file-panel resize handle
Word wrap never breaks inside a word, so a dataset name - one long token of
underscores - was laid out on a single line and cut off at the panel edge;
only a path with "/" in it wrapped at all. Break opportunities are now
marked at "/" first, at "_" next, and every twelve characters of a run with
neither, so the wrap uses the separators where they fit and breaks mid-name
only where nothing else will.

The file panel inset its contents by 4 px where every other dock sits flush,
which put its edge 4 px from the resize handle it shares with the image and
made the handle look off-centre; the handle between image and inspector was
symmetric, and now both are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:00:39 +02:00

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);
}