Files
Jungfraujoch/viewer/JFJochViewerMenu.cpp
leonarski_f 8261b7354e
Build Packages / Create release (push) Successful in 9s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m8s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 11m12s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 16m43s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 20m56s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 22m4s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 26m57s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m24s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 27m58s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 29m29s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 18m23s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m55s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / Build documentation (push) Successful in 1m32s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 27m20s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 29m27s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 32m41s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 34m46s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 37m25s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 35m56s
Build Packages / Unit tests (push) Successful in 2h8m44s
Build Packages / Validation Battery (push) Successful in 33m35s
v1.0.0-rc.176 (#86)
* jfjoch_broker: Optional live Rugnux analysis of DECTRIS rotation collections inside the broker, with merged data, maps and the placed model written beside the master file, the rotation lattice in the scan result, and a "Rugnux live" tab and dashboard row in the web frontend.
* Rugnux: Faster processing, with molecular replacement and model validation spread over several GPUs and run on the Apple GPU on macOS.
* Rugnux: The polarization stated in miniCBF and d*TREK headers is used, twinned crystals' space groups are decided more reliably, and a run that finds no lattice still reports every image.

Reviewed-on: #86
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-10-19 07:03:28 +02:00

393 lines
16 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 "ViewerTheme.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();});
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); });
viewTogglesAnchor = viewMenu->addSeparator();
QMenu *themeMenu = viewMenu->addMenu("Theme");
auto *themeGroup = new QActionGroup(this);
const ThemeChoice currentTheme = LoadThemeChoice();
const std::pair<ThemeChoice, QString> themes[] = {{ThemeChoice::System, "Follow system"},
{ThemeChoice::Light, "Light"},
{ThemeChoice::Dark, "Dark"}};
for (const auto &theme: themes) {
const ThemeChoice choice = theme.first;
QAction *action = themeMenu->addAction(theme.second);
action->setCheckable(true);
action->setChecked(choice == currentTheme);
themeGroup->addAction(action);
connect(action, &QAction::triggered, this, [choice] { SetThemeChoice(choice); });
// The display toolbar switches the theme too; keep the tick on the choice in force.
connect(ThemeNotifier::Instance(), &ThemeNotifier::changed, action,
[action, choice] { action->setChecked(LoadThemeChoice() == choice); });
}
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] { SetFontZoom(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] { SetFontZoom(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] { SetFontZoom(percent); });
}
SetFontZoom(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::SetFontZoom(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);
emit fontZoomChanged(fontZoom);
}
void JFJochViewerMenu::ResetFontZoom() {
SetFontZoom(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::AddViewToggle(QAction *action) {
viewMenu->insertAction(viewTogglesAnchor, action);
}
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>Build:</b> " + QString::fromStdString(jfjoch_build_info(true)).toHtmlEscaped() + "</p>"
"<p><b>Copyright:</b> 2019-2025 Paul Scherrer Institute</p>"
"<p><b>Author:</b> Filip Leonarski &lt;filip.leonarski@psi.ch&gt;</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);
// The dataset token, if whoever started the measurement protected it. Not persisted: it is a
// per-dataset secret, and the field is pre-filled only from what this session already holds
// (environment, D-Bus, an earlier dialog).
QLineEdit *tokenEdit = new QLineEdit(&dialog);
tokenEdit->setEchoMode(QLineEdit::Password);
tokenEdit->setPlaceholderText("optional");
tokenEdit->setText(http_token);
layout->addWidget(new QLabel("Token"), 1, 0);
layout->addWidget(tokenEdit, 1, 1, 1, 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, 2, 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());
http_token = tokenEdit->text();
emit httpTokenSelected(http_token); // reaches the worker before the open request below
emit fileOpenSelected(url, -1, 1, false);
}
}
void JFJochViewerMenu::setHttpToken(const QString &token) {
http_token = token;
}
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();
}
}