Files
Jungfraujoch/viewer/JFJochViewerMenu.cpp
T
leonarski_fandClaude Opus 5.5 9aae218086 Viewer: background slider on demand, Auto zeroes the background, no black plot line
- The background (black-point) slider is hidden by default. It is shown from
  View > Background slider, or as soon as the background becomes non-zero
  (B + wheel), so an active background is never invisible. The choice is kept
  in QSettings ("backgroundSlider") and cleared by "Reset all settings".
- Auto contrast, continuous or the one-shot A key, sets the background back
  to zero.
- A dataset-info plot without a run colour kept the default QPen: black, and
  near-invisible on the dark theme. This is every plot of a live HTTP dataset,
  whose run list is empty. It now takes #1F77B4 on the light theme and
  #FF7F0E on the dark one (the spots / background colours of the split plot).
  Checked headlessly: the rendered line pixels are exactly those colours on
  the #FFFFFF and #12142B plot backgrounds.

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

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