Build Packages / Create release (push) Successful in 23s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 7m30s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 7m41s
Build Packages / build:viewer-tgz:cpu (push) Successful in 8m9s
Build Packages / build:viewer-tgz:cuda (push) Successful in 9m19s
Build Packages / build:windows:nocuda (push) Successful in 16m37s
Build Packages / build:windows:cuda (push) Successful in 19m3s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 21m50s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 17m19s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 16m7s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 17m53s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 17m51s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 16m21s
Build Packages / Generate python client (push) Successful in 31s
Build Packages / build:rugnux:windows (push) Successful in 10m4s
Build Packages / Build documentation (push) Successful in 47s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 14m44s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 16m11s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 16m22s
Build Packages / build:rpm (rocky8) (push) Successful in 16m33s
Build Packages / build:rpm (rocky9) (push) Successful in 17m12s
Build Packages / Unit tests (push) Successful in 1h54m27s
Written for the person this program is mostly used by: someone reading a diffraction pattern on a 4K monitor with eyesight that is no longer 25. The rotation-angle axis of the dataset plot showed four ellipses and nothing else. Qt Charts has truncated axis labels by default since 6.2, so the plot has been unreadable along that axis for as long as we have shipped it; turning truncation off then exposed the last label clipped by the widget edge, hence the right margin that now follows the font instead of a hard 8 px. The splitters were 10 px of the window's own salmon - a 1.00:1 contrast ratio, which is to say invisible. They are now a darker coral band, 3.31:1, past the 3:1 that WCAG asks of a control's boundary, with the brand coral as the hover state. The brand colour itself is 2.38:1 on salmon and cannot carry the idle band. View > Font size, Ctrl+plus and Ctrl+minus, 100/125/150 %. It multiplies whatever the desktop already set rather than replacing it, so a session already scaled by GNOME steps from there and steps back exactly. Qt does not deliver a runtime application font to widgets that already exist, so the change is broadcast to them; per-widget attributes survive it. The Inspector is the panel people read all day, and its value column was capped at a flat 600 px, which clipped as soon as the text grew. The bounds are character widths now, chosen to be exactly 600 at the default font and to grow with it. The dataset plot's curves are a fifth of the font height rather than 2 px, for the same reason: a curve is read at the distance the labels beside it are. Also the four QChart::axisX/axisY calls deprecated since 6.0, replaced by what they do internally, and the two button rows the larger fonts clipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011GxZqDiFP3KqriBhNdcR56
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);;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();
|
|
}
|
|
}
|