Two more of the formats deposited data actually arrives in, found by processing a
corpus of it: every PETRA III EMBL set is .cbf.gz, and NSRRC and the whole ADSC
Quantum era are SMV. Both were previously "no native input".
SMV is an ASCII "KEY=value;" block between braces, then the pixels - no container,
no compression, nothing to decode by offset - so reader/SMV.{h,cpp} and
JFJochSMVReader are a smaller job than the marCCD pair they sit beside, and need no
new dependency at all. Two things the format does not give us, both said out loud
rather than papered over:
* It states no saturation value, so overloads are judged on the 16-bit container
alone. That can only fail to call a pixel saturated, never condemn a good one,
but a CCD at the top of its range does saturate, so the reader warns once.
* Its beam centre is in MILLIMETRES and which of X/Y is the fast direction is a
convention rather than a rule. Measured on one ALS ADSC sweep the file's value is
TRANSPOSED: as stated it indexes 2/60 frames, and the run's own beam-centre
measurement (which adopts the right one automatically) indexes 60/60. Swapping it
here would fit that writer and might break another, so the header is read as the
format defines it and the measurement stays the arbiter. Revisit with a second
vendor's SMV in hand.
.cbf.gz needed only Slurp() in MiniCBF.cpp, through which every read already passes:
it sniffs the two-byte gzip magic - not the file name - and takes a zlib path when it
is there, leaving the plain path free of zlib's buffer copy. zlib-ng is already in the
build, so this is a link line, not a dependency. The sweep template grew a suffix,
because ".cbf" and ".cbf.gz" are separate sweeps and std::filesystem cannot split the
double extension on its own.
The viewer's single cbf_reader becomes three, dispatched by CanRead() in the same
order as rugnux. Dispatch is by CONTENT in both: ".img" is used by miniCBF, marCCD
AND SMV depending on the writer, and a PDB detector label has now been wrong about
the format four times, so an extension decides nothing.
Measured, de novo, no flags: 9fcg (1800 gzipped frames) gives P4 and a cell 0.06%
from the deposited one at 1.37 A against a deposited 1.54; 6oel (ADSC SMV) gives
F4132 - 96 operations, the most a protein space group can have - and a cell 0.05%
out, 100% indexed. Tests cover both formats and the transposed-beam-centre case with
fixtures written byte for byte, so they need no external data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
351 lines
14 KiB
C++
351 lines
14 KiB
C++
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "JFJochViewerMenu.h"
|
|
#include "RemoteDisplayMode.h"
|
|
|
|
#include <QMessageBox>
|
|
#include <QApplication>
|
|
#include <QDir>
|
|
#include <QFileDialog>
|
|
#include <QString>
|
|
#include <QGridLayout>
|
|
#include <QLabel>
|
|
#include <QLineEdit>
|
|
#include <QSpinBox>
|
|
#include <QComboBox>
|
|
#include <QDialogButtonBox>
|
|
#include <QDockWidget>
|
|
#include <QSettings>
|
|
|
|
#include <algorithm>
|
|
|
|
#include "JFJochViewerWindow.h"
|
|
#include "windows/JFJochLicenseWindow.h"
|
|
#include "windows/JFJochMouseShortcutsWindow.h"
|
|
#include "windows/JFJochAcknowledgementWindow.h"
|
|
#include "../common/GitInfo.h"
|
|
#include "../common/CUDAWrapper.h"
|
|
|
|
JFJochViewerMenu::JFJochViewerMenu(QWidget *parent) : QMenuBar(parent) {
|
|
// Create "File" menu
|
|
QMenu *fileMenu = addMenu("File");
|
|
|
|
QAction *openAction = fileMenu->addAction("Open");
|
|
openAction->setShortcut(QKeySequence::Open);
|
|
connect(openAction, &QAction::triggered, this, &JFJochViewerMenu::openSelected);
|
|
|
|
QAction *openHttpAction = fileMenu->addAction("Open HTTP");
|
|
openHttpAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_H));
|
|
connect(openHttpAction, &QAction::triggered, this, &JFJochViewerMenu::openHttpSelected);
|
|
|
|
QAction *closeAction = fileMenu->addAction("Close");
|
|
closeAction->setShortcut(QKeySequence::Close);
|
|
connect(closeAction, &QAction::triggered, this, &JFJochViewerMenu::closeSelected);
|
|
|
|
// Add seperator
|
|
fileMenu->addSeparator();
|
|
|
|
// Add "Quit" action
|
|
QAction *quitAction = fileMenu->addAction("Quit");
|
|
quitAction->setShortcut(QKeySequence::Quit);
|
|
connect(quitAction, &QAction::triggered, this, &JFJochViewerMenu::quitSelected);
|
|
|
|
QMenu *maskMenu = addMenu("Mask");
|
|
const QAction *saveMaskTiff = maskMenu->addAction("Save User Mask as TIFF...");
|
|
connect(saveMaskTiff, &QAction::triggered, this, &JFJochViewerMenu::saveUserMaskAsTiffSelected);
|
|
|
|
const QAction *loadMaskReplace = maskMenu->addAction("Load User Mask from TIFF (replace)...");
|
|
connect(loadMaskReplace, &QAction::triggered, [this] { openLoadUserMaskTiff(true); });
|
|
|
|
const QAction *loadMaskAdd = maskMenu->addAction("Load User Mask from TIFF (add)...");
|
|
connect(loadMaskAdd, &QAction::triggered, [this] { openLoadUserMaskTiff(false); });
|
|
|
|
const QAction *uploadMask = maskMenu->addAction("&Upload User Mask");
|
|
connect(uploadMask, &QAction::triggered, this, &JFJochViewerMenu::uploadUserMaskAction);
|
|
|
|
const QAction *clearMask = maskMenu->addAction("Clear User Mask");
|
|
connect(clearMask, &QAction::triggered, [this] () {emit clearUserMaskSelected();});
|
|
|
|
windowMenu = addMenu("Window");
|
|
|
|
QMenu *dockMenu = addMenu("Charts");
|
|
const QAction *dockCalibration = dockMenu->addAction("New dataset info plot");
|
|
connect(dockCalibration, &QAction::triggered, [this] { emit openDatasetInfo();});
|
|
|
|
QMenu *viewMenu = addMenu("View");
|
|
const QAction *imageLayout = viewMenu->addAction("Image layout");
|
|
connect(imageLayout, &QAction::triggered, this, &JFJochViewerMenu::imageLayoutSelected);
|
|
const QAction *processingLayout = viewMenu->addAction("Processing layout");
|
|
connect(processingLayout, &QAction::triggered, this, &JFJochViewerMenu::processingLayoutSelected);
|
|
viewMenu->addSeparator();
|
|
const QAction *resetLayout = viewMenu->addAction("Reset layout");
|
|
connect(resetLayout, &QAction::triggered, this, &JFJochViewerMenu::resetLayoutSelected);
|
|
const QAction *resetSettings = viewMenu->addAction("Reset all settings to defaults");
|
|
connect(resetSettings, &QAction::triggered, this, &JFJochViewerMenu::resetSettingsSelected);
|
|
viewMenu->addSeparator();
|
|
// Auto-detected from the session (ssh -X and the like); this toggle is the manual override.
|
|
QAction *remoteMode = viewMenu->addAction("Remote display mode (limit repaints)");
|
|
remoteMode->setCheckable(true);
|
|
remoteMode->setChecked(RemoteDisplayMode());
|
|
connect(remoteMode, &QAction::toggled, this, [](bool on) { SetRemoteDisplayMode(on); });
|
|
|
|
viewMenu->addSeparator();
|
|
// The desktop's own text scaling stays the primary mechanism, but few users know where it lives,
|
|
// so the viewer carries a zoom of its own. It multiplies the font the desktop handed us, so it
|
|
// composes with GNOME's "Large Text" or QT_SCALE_FACTOR instead of fighting them.
|
|
baseFont = QApplication::font();
|
|
QMenu *fontMenu = viewMenu->addMenu("Font size");
|
|
QAction *fontLarger = fontMenu->addAction("Larger");
|
|
fontLarger->setShortcuts({QKeySequence::ZoomIn,
|
|
QKeySequence(Qt::ControlModifier | Qt::Key_Equal),
|
|
QKeySequence(Qt::ControlModifier | Qt::KeypadModifier | Qt::Key_Plus)});
|
|
connect(fontLarger, &QAction::triggered, this, [this] { ApplyFontZoom(fontZoom + 25); });
|
|
QAction *fontSmaller = fontMenu->addAction("Smaller");
|
|
fontSmaller->setShortcuts({QKeySequence::ZoomOut,
|
|
QKeySequence(Qt::ControlModifier | Qt::KeypadModifier | Qt::Key_Minus)});
|
|
connect(fontSmaller, &QAction::triggered, this, [this] { ApplyFontZoom(fontZoom - 25); });
|
|
fontMenu->addSeparator();
|
|
fontZoomGroup = new QActionGroup(this);
|
|
for (int percent: {100, 125, 150}) {
|
|
QAction *level = fontMenu->addAction(QString("%1 %").arg(percent));
|
|
level->setCheckable(true);
|
|
level->setData(percent);
|
|
fontZoomGroup->addAction(level);
|
|
connect(level, &QAction::triggered, this, [this, percent] { ApplyFontZoom(percent); });
|
|
}
|
|
ApplyFontZoom(QSettings("PSI", "jfjoch_viewer").value("fontZoom", 100).toInt());
|
|
|
|
QMenu *helpMenu = addMenu("Help");
|
|
// Add "About" action
|
|
const QAction *aboutAction = helpMenu->addAction("About");
|
|
connect(aboutAction, &QAction::triggered, this, &JFJochViewerMenu::aboutSelected);
|
|
|
|
const QAction *mouseShortcutsAction = helpMenu->addAction("Mouse Shortcuts");
|
|
connect(mouseShortcutsAction, &QAction::triggered, this, &JFJochViewerMenu::mouseShortcutsSelected);
|
|
|
|
const QAction *acknowledgementAction = helpMenu->addAction("Acknowledgements");
|
|
connect(acknowledgementAction, &QAction::triggered, this, &JFJochViewerMenu::acknowledgementsSelected);
|
|
|
|
const QAction *licensesAction = helpMenu->addAction("Third-party Licenses");
|
|
connect(licensesAction, &QAction::triggered, this, &JFJochViewerMenu::licensesSelected);
|
|
}
|
|
|
|
// Three levels, 25 % apart: clamping here is what stops the two stepping actions at the ends.
|
|
void JFJochViewerMenu::ApplyFontZoom(int percent) {
|
|
fontZoom = std::clamp(percent, 100, 150);
|
|
QFont font = baseFont;
|
|
font.setPointSizeF(baseFont.pointSizeF() * fontZoom / 100.0);
|
|
QApplication::setFont(font);
|
|
// setFont on its own only reaches widgets built after it. This is what carries the new size into
|
|
// the ones already on screen, and it keeps each widget's own attributes (a bold title stays bold).
|
|
QEvent font_change(QEvent::ApplicationFontChange);
|
|
for (QWidget *widget: QApplication::allWidgets())
|
|
QApplication::sendEvent(widget, &font_change);
|
|
QSettings("PSI", "jfjoch_viewer").setValue("fontZoom", fontZoom);
|
|
for (QAction *level: fontZoomGroup->actions())
|
|
level->setChecked(level->data().toInt() == fontZoom);
|
|
}
|
|
|
|
void JFJochViewerMenu::ResetFontZoom() {
|
|
ApplyFontZoom(100);
|
|
}
|
|
|
|
void JFJochViewerMenu::AddWindowEntry(JFJochHelperWindow *window, const QString &name) {
|
|
auto action = windowMenu->addAction(name);
|
|
action->setCheckable(true);
|
|
action->setChecked(false);
|
|
connect(action, &QAction::toggled, window, &JFJochHelperWindow::toggle);
|
|
connect(window, &JFJochHelperWindow::closing, [action] {action->setChecked(false);});
|
|
}
|
|
|
|
void JFJochViewerMenu::AddDockEntry(QDockWidget *dock, const QString &name) {
|
|
auto *action = dock->toggleViewAction(); // checkable, stays in sync with the dock visibility
|
|
action->setText(name);
|
|
windowMenu->addAction(action);
|
|
}
|
|
|
|
|
|
void JFJochViewerMenu::aboutSelected() {
|
|
QPixmap pixmap(":/jfjoch.png");
|
|
|
|
QString version(QString::fromStdString(jfjoch_version()));
|
|
// Create QMessageBox
|
|
QMessageBox aboutBox(this);
|
|
aboutBox.setWindowTitle("About");
|
|
|
|
QString about_text = "<h3>Jungfraujoch Image Viewer</h3>" // Use HTML for improved style
|
|
"<p><b>Version:</b> " + version + "</p>"
|
|
"<p><b>Copyright:</b> 2019-2025 Paul Scherrer Institute</p>"
|
|
"<p><b>Author:</b> Filip Leonarski <filip.leonarski@psi.ch></p>"
|
|
"<p><b>Links:</b> <a href='https://jungfraujoch.readthedocs.io'>Documentation</a>, "
|
|
"<a href='https://gitea.psi.ch/mx/jungfraujoch'>Repository</a></p>";
|
|
|
|
#ifdef JFJOCH_USE_CUDA
|
|
about_text += QString("<p>Compiled with CUDA support. %1 GPU(s) detected</p>")
|
|
.arg(QString::number(get_gpu_count()));
|
|
#else
|
|
about_text += "<p><b>Note:</b> CUDA is not available on this system.</p>";
|
|
#endif
|
|
// Set detailed text with proper spacing and structure
|
|
aboutBox.setText(about_text);
|
|
|
|
// Optional: Add a larger informative text, if needed
|
|
aboutBox.setInformativeText(
|
|
"This program comes with ABSOLUTELY NO WARRANTY. "
|
|
"This is free software, and you are welcome to redistribute it "
|
|
"under certain conditions (GPLv3).\n\n"
|
|
"Development supported by Innovation Project (101.535.1 IP-ENG) from Innosuisse."
|
|
);
|
|
|
|
// Set an icon
|
|
aboutBox.setIconPixmap(pixmap);
|
|
|
|
// Execute the dialog
|
|
aboutBox.exec();
|
|
}
|
|
|
|
void JFJochViewerMenu::licensesSelected() {
|
|
// Lazily create the modeless window and keep it; raise it if already open.
|
|
if (licenseWindow == nullptr)
|
|
licenseWindow = new JFJochLicenseWindow(window());
|
|
licenseWindow->show();
|
|
licenseWindow->raise();
|
|
licenseWindow->activateWindow();
|
|
}
|
|
|
|
void JFJochViewerMenu::mouseShortcutsSelected() {
|
|
// Same pattern as the license window: modeless, created once, raised if already open.
|
|
if (mouseShortcutsWindow == nullptr)
|
|
mouseShortcutsWindow = new JFJochMouseShortcutsWindow(window());
|
|
mouseShortcutsWindow->show();
|
|
mouseShortcutsWindow->raise();
|
|
mouseShortcutsWindow->activateWindow();
|
|
}
|
|
|
|
void JFJochViewerMenu::acknowledgementsSelected() {
|
|
// Same pattern as the license window: modeless, created once, raised if already open.
|
|
if (acknowledgementWindow == nullptr)
|
|
acknowledgementWindow = new JFJochAcknowledgementWindow(window());
|
|
acknowledgementWindow->show();
|
|
acknowledgementWindow->raise();
|
|
acknowledgementWindow->activateWindow();
|
|
}
|
|
|
|
void JFJochViewerMenu::openSelected() {
|
|
QString fileName = QFileDialog::getOpenFileName(
|
|
this,
|
|
"Open File", // Dialog title
|
|
"", // Default folder
|
|
// A CBF is one frame per file: picking any frame opens the sweep it belongs to.
|
|
"HDF5 Master Files (*_master.h5 *_process.h5);; HDF5 Files (*.h5);; CBF Images (*.cbf *.cbf.gz);; CCD Images (*.img *.mccd *.smv);;All Files (*)"
|
|
);
|
|
|
|
if (!fileName.isEmpty())
|
|
// Qt returns '/'-separated paths even on Windows, but HDF5's H5Fopen needs native separators
|
|
// to recognise a UNC path (\\host\share, e.g. the WSL2 filesystem): '//host/share' is rejected,
|
|
// while 'C:/...' still works, which is exactly the reported symptom. No-op on Linux.
|
|
emit fileOpenSelected(QDir::toNativeSeparators(fileName), 0, 1, false);
|
|
}
|
|
|
|
void JFJochViewerMenu::closeSelected() {
|
|
emit fileCloseSelected();
|
|
}
|
|
|
|
void JFJochViewerMenu::quitSelected() {
|
|
// Through close(), not QApplication::quit(): closeEvent is where the persistent settings
|
|
// are saved, and quit() would bypass it.
|
|
window()->close();
|
|
}
|
|
|
|
|
|
void JFJochViewerMenu::openHttpSelected() {
|
|
QDialog dialog(this);
|
|
dialog.setWindowTitle("Open HTTP Connection");
|
|
dialog.setMinimumWidth(400);
|
|
|
|
QGridLayout *layout = new QGridLayout(&dialog);
|
|
|
|
// Scheme selector: plain HTTP (broker directly) or HTTPS (e.g. broker behind a reverse proxy).
|
|
QComboBox *schemeCombo = new QComboBox(&dialog);
|
|
schemeCombo->addItem("http://");
|
|
schemeCombo->addItem("https://");
|
|
layout->addWidget(schemeCombo, 0, 0);
|
|
layout->addWidget(new QLabel(":"), 0, 2);
|
|
|
|
QLineEdit *hostEdit = new QLineEdit(&dialog);
|
|
|
|
char *host = std::getenv("JUNGFRAUJOCH_HTTP_HOST");
|
|
if (host != nullptr)
|
|
hostEdit->setText(host);
|
|
else
|
|
hostEdit->setText("localhost");
|
|
layout->addWidget(hostEdit, 0, 1);
|
|
|
|
|
|
QSpinBox *portSpinBox = new QSpinBox(&dialog);
|
|
portSpinBox->setRange(1, 65535);
|
|
|
|
char *port_std = std::getenv("JUNGFRAUJOCH_HTTP_PORT");
|
|
if (port_std != nullptr) {
|
|
const int tmp = std::stoi(std::string(port_std));
|
|
if (tmp < 65536 && tmp > 0)
|
|
portSpinBox->setValue(tmp);
|
|
} else
|
|
portSpinBox->setValue(8080);
|
|
|
|
layout->addWidget(portSpinBox, 0, 3);
|
|
|
|
QDialogButtonBox *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
|
connect(buttonBox, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
|
|
connect(buttonBox, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
|
|
layout->addWidget(buttonBox, 1, 0, 1, 4);
|
|
|
|
dialog.setLayout(layout);
|
|
|
|
if (dialog.exec() == QDialog::Accepted) {
|
|
QString url = QString("%1%2:%3")
|
|
.arg(schemeCombo->currentText())
|
|
.arg(hostEdit->text())
|
|
.arg(portSpinBox->value());
|
|
|
|
emit fileOpenSelected(url, -1, 1, false);
|
|
}
|
|
}
|
|
|
|
void JFJochViewerMenu::saveUserMaskAsTiffSelected() {
|
|
QString filename = QFileDialog::getSaveFileName(
|
|
this,
|
|
"Save User Mask as TIFF",
|
|
"",
|
|
"TIFF Images (*.tif *.tiff);;All Files (*)"
|
|
);
|
|
if (!filename.isEmpty()) {
|
|
emit saveUserMaskTiffSelected(filename);
|
|
}
|
|
}
|
|
|
|
void JFJochViewerMenu::openLoadUserMaskTiff(bool replace) {
|
|
QString filename = QFileDialog::getOpenFileName(
|
|
this,
|
|
replace ? "Load User Mask from TIFF (replace)" : "Load User Mask from TIFF (add)",
|
|
"",
|
|
"TIFF Images (*.tif *.tiff);;All Files (*)"
|
|
);
|
|
if (!filename.isEmpty())
|
|
emit loadUserMaskTiffSelected(filename, replace);
|
|
}
|
|
|
|
void JFJochViewerMenu::uploadUserMaskAction() {
|
|
auto reply = QMessageBox::question(
|
|
this,
|
|
"Upload User Mask",
|
|
"Upload current user mask to the server?",
|
|
QMessageBox::Yes | QMessageBox::No,
|
|
QMessageBox::No
|
|
);
|
|
if (reply == QMessageBox::Yes) {
|
|
emit uploadUserMaskSelected();
|
|
}
|
|
}
|