From d24cdfcff4ae3c4ae18c4c024efcacea330eb557 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Fri, 11 Sep 2026 17:12:45 +0200 Subject: [PATCH] jfjoch_viewer: navigation keys, one-shot auto-contrast, file manager panel, cleanup * Dataset-info plot hover shows a horizontal crosshair alongside the existing vertical one, so a run of hovers reads as a level or a drift at a glance. * `A` applies auto-contrast once instead of switching on persistent Auto mode (a new `oneShotAutoForeground()`, distinct from the toolbar's Auto toggle); pressing it a second time on the same image switches Auto on for good, and pressing it while Auto is already on leaves it on. The value itself comes from one `AutoForegroundValue()` shared with the continuous Auto path. * `Home`/`End`/`Page Up`/`Page Down` navigate the dataset (first/last image, one image forward/back); claimed in the diffraction view's keyPressEvent before QGraphicsView's default handling, which otherwise eats them to scroll the viewport. * Alt+wheel dataset navigation is removed - some window managers already took it before the app ever saw it, per the caveat that used to sit next to its entry in the shortcuts list. * The image strip dock and its dedicated thumbnail-rendering machinery in the reading worker (SetThumbnail*, RenderThumbnail_i) are removed; it cost a lot and nothing else used any of it. * Toolbar text: "Colour" -> "Color". * Color map, Auto and HDR mode now persist across sessions, the same way the window layout already does. * The Inspector's "Dataset:" path wraps at '/' (a zero-width space after each one) instead of being cut off when it doesn't fit one line. * LoadFile no longer reopens an already-open file - it forwards straight to LoadImage instead - which was the likely cause of a D-Bus client's per-image navigation lagging behind the same navigation done via the grid-scan hover. * New file-manager dock (left, tabbed with Settings via tabifyDockWidget): a directory tree filtered to *_master.h5/*_process.h5, rooted at JUNGFRAUJOCH_DATA_ROOT if set, else the last-browsed root, else the home directory. Most beamline users care about opening images, not reprocessing settings, so it's the tab raised by default. Its filter box narrows files only - a directory always passes - because filtering the directories too hid the matching files under any directory whose own name did not match, and hid the root's ancestors, which left the tree with no valid root index until the root was set again by hand. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PogHY7bWXV4bpctDPdyuDN Co-Authored-By: Claude Opus 5 (1M context) --- docs/JFJOCH_VIEWER.md | 17 +- viewer/CMakeLists.txt | 4 +- viewer/JFJochImageReadingWorker.cpp | 137 +------------ viewer/JFJochImageReadingWorker.h | 17 -- viewer/JFJochViewerWindow.cpp | 78 +++---- viewer/JFJochViewerWindow.h | 3 +- viewer/charts/JFJochDatasetInfoChartView.cpp | 39 +++- viewer/charts/JFJochDatasetInfoChartView.h | 3 +- .../image_viewer/JFJochDiffractionImage.cpp | 67 +++++- viewer/image_viewer/JFJochDiffractionImage.h | 7 + viewer/image_viewer/JFJochImage.cpp | 10 - viewer/image_viewer/JFJochImage.h | 3 +- viewer/toolbar/JFJochViewerToolbarDisplay.cpp | 14 +- viewer/toolbar/JFJochViewerToolbarDisplay.h | 7 + viewer/widgets/JFJochViewerFileBrowser.cpp | 130 ++++++++++++ viewer/widgets/JFJochViewerFileBrowser.h | 43 ++++ .../widgets/JFJochViewerImageStatistics.cpp | 8 +- viewer/widgets/JFJochViewerImageStrip.cpp | 192 ------------------ viewer/widgets/JFJochViewerImageStrip.h | 50 ----- viewer/windows/JFJochMouseShortcutsWindow.cpp | 8 +- 20 files changed, 375 insertions(+), 462 deletions(-) create mode 100644 viewer/widgets/JFJochViewerFileBrowser.cpp create mode 100644 viewer/widgets/JFJochViewerFileBrowser.h delete mode 100644 viewer/widgets/JFJochViewerImageStrip.cpp delete mode 100644 viewer/widgets/JFJochViewerImageStrip.h diff --git a/docs/JFJOCH_VIEWER.md b/docs/JFJOCH_VIEWER.md index 8edccd920..c2cc71afa 100644 --- a/docs/JFJOCH_VIEWER.md +++ b/docs/JFJOCH_VIEWER.md @@ -57,8 +57,8 @@ install it. CLI takes. - Auxiliary windows: image list, dataset metadata, spot list, reflection list, reciprocal-space viewer, 2D azimuthal-integration image, calibration-image viewer and a magnifier; plus the - *Inspector* (per-image statistics, image features, resolution rings, ROI statistics), the - *Image strip* thumbnail feed and dataset-info charts. + *Inspector* (per-image statistics, image features, resolution rings, ROI statistics) and + dataset-info charts. - The *Inspector*'s **Image features** section decides what the overlay draws — spots, predictions, saturated and highest pixels, the beam stop — including whether the non-indexed spots and the spots that fall on an ice ring are drawn at all. @@ -98,9 +98,10 @@ The same list is available in the application under **Help ▸ Mouse Shortcuts** | Wheel | Zoom in / out, centred on the cursor | | `Shift` + wheel | Move the foreground (upper contrast limit) in linear steps | | `Ctrl` + wheel | Move the foreground in multiplicative steps (×1.15 per notch) | -| `Alt` + wheel | Step through the dataset, one image per notch (up = next image) | | `F` held + wheel | Same as `Shift` + wheel, for as long as `F` is held | -| `A` | Switch on the automatic foreground | +| `A` | Apply auto-contrast once; press it again to switch on continuous Auto | +| `Home` / `End` | Jump to the first / last image in the dataset | +| `Page Up` / `Page Down` | Step one image forward / back | | Hover | Status bar shows the pixel position, its value and the resolution | | Drag | Pan the image | | `Shift` + drag | Draw a rectangular ROI | @@ -108,9 +109,6 @@ The same list is available in the application under **Help ▸ Mouse Shortcuts** | Drag an ROI or its handle | Move or resize the selected ROI | | Right click | Copy / save the image, fit to view, clear the ROI | -Some window managers take `Alt`-modified mouse events for themselves before the application sees -them; that is a window-manager setting, not something the viewer can take back. - ### Grid scan | Action | Effect | @@ -153,7 +151,10 @@ can drive it: and display the given image. - `LoadImage(image_number, summation=1)` — navigate to an image in the already-open dataset. -`summation` sums that many consecutive images before display. +`summation` sums that many consecutive images before display. A repeated `LoadFile` call naming the +file that is already open is cheap (it just navigates, like `LoadImage`) rather than reopening it, +but a client stepping through images of a dataset it opened itself should still prefer `LoadImage` — +it needs no filename and avoids the file-identity comparison. ## Building from source on Windows diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index 482cb72c2..8aeb5eeef 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -70,8 +70,8 @@ ADD_EXECUTABLE(jfjoch_viewer jfjoch_viewer.cpp JFJochViewerWindow.cpp JFJochView widgets/CollapsibleSection.h widgets/ToolbarIcons.cpp widgets/ToolbarIcons.h - widgets/JFJochViewerImageStrip.cpp - widgets/JFJochViewerImageStrip.h + widgets/JFJochViewerFileBrowser.cpp + widgets/JFJochViewerFileBrowser.h windows/JFJochViewerSpotListWindow.cpp windows/JFJochViewerSpotListWindow.h image_viewer/JFJochAzIntImage.cpp diff --git a/viewer/JFJochImageReadingWorker.cpp b/viewer/JFJochImageReadingWorker.cpp index e1893f564..104a564da 100644 --- a/viewer/JFJochImageReadingWorker.cpp +++ b/viewer/JFJochImageReadingWorker.cpp @@ -21,8 +21,6 @@ #include #include #include -#include -#include #include #include "../preview/JFJochTIFF.h" @@ -110,7 +108,6 @@ JFJochImageReadingWorker::JFJochImageReadingWorker(const SpotFindingSettings &se http_reader.Experiment(experiment); hdf5_reader.Experiment(experiment); cbf_reader.Experiment(experiment); - thumb_color_scale_.Select(ColorScaleEnum::Indigo); autoload_timer = new QTimer(this); autoload_timer->setInterval(autoload_interval); @@ -205,6 +202,15 @@ void JFJochImageReadingWorker::FileOpenRetryTimerExpired() { void JFJochImageReadingWorker::LoadFile_i(const QString &filename, qint64 image_number, qint64 summation, bool retry) { try { + // The file (in file mode) is already open: nothing to (re)read, just move to the requested + // image. Without this, a caller that re-issues LoadFile per image instead of LoadImage (e.g. + // a D-Bus client stepping through a dataset) pays a full close+reopen+reparse of the HDF5 + // dataset for every single image, instead of the cheap single-image read LoadImage does. + if (!http_mode && !filename.isEmpty() && filename == current_file) { + LoadImage_i(image_number, summation); + return; + } + std::shared_ptr dataset; auto start = std::chrono::high_resolution_clock::now(); @@ -1162,128 +1168,3 @@ void JFJochImageReadingWorker::SetActiveSnapshot(QString name) { } } - -void JFJochImageReadingWorker::SetThumbnailColorMap(int color_map) { - QMutexLocker locker(&m); - thumb_color_scale_.Select(static_cast(color_map)); -} - -void JFJochImageReadingWorker::SetThumbnailFeatureColor(QColor c) { - QMutexLocker locker(&m); - thumb_feature_color_ = c; -} - -void JFJochImageReadingWorker::SetThumbnailSpotColor(QColor c) { - QMutexLocker locker(&m); - thumb_spot_color_ = c; -} - -void JFJochImageReadingWorker::RenderThumbnails(QVector image_numbers, bool show_spots) { - // File mode only; live HTTP cannot address arbitrary images cheaply. - if (http_mode) - return; - for (qint64 n : image_numbers) { - QImage thumb = RenderThumbnail_i(n, show_spots); - if (!thumb.isNull()) - emit thumbnailReady(n, thumb); - } -} - -QImage JFJochImageReadingWorker::RenderThumbnail_i(int64_t image_number, bool show_spots) { - // Everything runs inside one try: an uncaught exception here would be on the worker thread and - // would std::terminate the whole app. On any failure we just skip this thumbnail. - try { - std::shared_ptr img; - { - QMutexLocker locker(&m); - if (http_mode || image_number < 0 || image_number >= total_images) - return {}; - img = file_reader->LoadImage(image_number, 1); - } - if (!img) - return {}; - - const int W = static_cast(img->Dataset().experiment.GetXPixelsNum()); - const int H = static_cast(img->Dataset().experiment.GetYPixelsNum()); - const auto &px = img->Image(); - if (W <= 0 || H <= 0 || static_cast(px.size()) < static_cast(W) * H) - return {}; - - const auto &lut = thumb_color_scale_.LUTData(); - const int lutSize = static_cast(lut.size()); - if (lutSize <= 0) - return {}; - - // Downsample by block-maximum (keeps sharp Bragg spots), then map through the LUT. - constexpr int maxDim = 120; - const double s = static_cast(maxDim) / std::max(W, H); - const int tw = std::max(1, static_cast(W * s)); - const int th = std::max(1, static_cast(H * s)); - const int bx = std::max(1, W / tw); - const int by = std::max(1, H / th); - - const float fg = std::max(1.0f, static_cast(img->GetAutoContrastValue())); - const float invRange = (lutSize - 1) / fg; - const rgb gap = thumb_color_scale_.Apply(ColorScaleSpecial::Gap); - - QImage out(tw, th, QImage::Format_RGB32); - for (int ty = 0; ty < th; ++ty) { - QRgb *line = reinterpret_cast(out.scanLine(ty)); - const int y1 = std::min(H, ty * by + by); - for (int tx = 0; tx < tw; ++tx) { - const int x1 = std::min(W, tx * bx + bx); - int32_t best = 0; - bool any = false; - for (int yy = ty * by; yy < y1; ++yy) - for (int xx = tx * bx; xx < x1; ++xx) { - const int32_t v = px[yy * W + xx]; - if (v == GAP_PXL_VALUE || v == ERROR_PXL_VALUE || v == BEAM_STOP_PXL_VALUE) continue; - if (v == SATURATED_PXL_VALUE) { best = static_cast(fg); any = true; continue; } - if (!any || v > best) { best = v; any = true; } - } - rgb c; - if (!any) { - c = gap; - } else { - int idx = static_cast(std::max(0, best) * invRange + 0.5f); - idx = std::clamp(idx, 0, lutSize - 1); - c = lut[idx]; - } - line[tx] = qRgb(c.r, c.g, c.b); - } - } - - if (show_spots) { - QPainter p(&out); - p.setRenderHint(QPainter::Antialiasing); - for (const auto &sp : img->ImageData().spots) { - p.setPen(QPen(sp.indexed ? thumb_feature_color_ : thumb_spot_color_, 1.0)); - p.drawEllipse(QPointF(sp.x * s, sp.y * s), 1.6, 1.6); - } - } - - // Image-number badge in the top-left, so the strip needs no text label (and loses no height). - { - QPainter p(&out); - p.setRenderHint(QPainter::Antialiasing); - QFont f = p.font(); - f.setBold(true); - f.setPixelSize(11); - p.setFont(f); - const QString label = QString::number(image_number + 1); - const QRect tb = QFontMetrics(f).boundingRect(label); - const QRect badge(2, 2, tb.width() + 8, tb.height() + 2); - p.setPen(Qt::NoPen); - p.setBrush(QColor(0xFA, 0x72, 0x68)); // coral - p.drawRoundedRect(badge, 3, 3); - p.setPen(Qt::white); - p.drawText(badge, Qt::AlignCenter, label); - } - return out; - } catch (const std::exception &e) { - logger.Debug("Thumbnail render failed for image {}: {}", image_number, e.what()); - return {}; - } catch (...) { - return {}; - } -} diff --git a/viewer/JFJochImageReadingWorker.h b/viewer/JFJochImageReadingWorker.h index 6bb2b7f4f..ca9adc833 100644 --- a/viewer/JFJochImageReadingWorker.h +++ b/viewer/JFJochImageReadingWorker.h @@ -10,10 +10,6 @@ #include #include #include -#include -#include - -#include "../common/ColorScale.h" #include "../reader/JFJochHDF5Reader.h" #include "../reader/JFJochCBFReader.h" @@ -112,11 +108,6 @@ private: SpotFindingSettings spot_finding_settings; - ColorScale thumb_color_scale_; // colour map used for the image-strip thumbnails - QColor thumb_feature_color_ = Qt::magenta; // indexed-spot overlay (matches the main viewer) - QColor thumb_spot_color_ = Qt::green; // non-indexed spot overlay - QImage RenderThumbnail_i(int64_t image_number, bool show_spots); - std::optional current_image; int64_t current_summation = 1; int64_t total_images = 0; @@ -206,7 +197,6 @@ signals: void snapshotsChanged(QStringList names, QString active); void runsChanged(QVector runs, QString active_id); void fileOpened(); // a new file/stream was loaded - listeners should reset per-file state - void thumbnailReady(qint64 image_number, QImage thumb); // for the image-strip / hit feed void referenceMtzChanged(ReferenceMtzInfo info); // a reference MTZ was (un)loaded - update the dock public: @@ -274,11 +264,4 @@ public slots: void SetActiveSnapshot(QString name); void RenameRun(QString id, QString label); // change a run's display label (legend) void RemoveRun(QString id); // drop a reprocessing snapshot (not "Original") - - // Render downsampled thumbnails for the given images (file mode only), emitting thumbnailReady - // for each; non-disruptive (does not change the displayed image). - void RenderThumbnails(QVector image_numbers, bool show_spots); - void SetThumbnailColorMap(int color_map); - void SetThumbnailFeatureColor(QColor c); - void SetThumbnailSpotColor(QColor c); }; diff --git a/viewer/JFJochViewerWindow.cpp b/viewer/JFJochViewerWindow.cpp index c48730c5b..90ccd3808 100644 --- a/viewer/JFJochViewerWindow.cpp +++ b/viewer/JFJochViewerWindow.cpp @@ -17,10 +17,11 @@ #include #include "JFJochImageReadingWorker.h" +#include "../common/ColorScale.h" #include "image_viewer/JFJochDiffractionImage.h" #include "JFJochViewerSidePanel.h" #include "widgets/JFJochViewerSettingsDock.h" -#include "widgets/JFJochViewerImageStrip.h" +#include "widgets/JFJochViewerFileBrowser.h" #include "JFJochViewerStatusBar.h" #include "../common/CUDAWrapper.h" #include "../rugnux/RugnuxDefaults.h" @@ -46,7 +47,7 @@ // Dock-layout version for saveState/restoreState: bump whenever the dock set / structure changes so a // stale saved layout (which can restore a dock as a broken, non-responsive floating window) is rejected // and the default layout applies instead. -static constexpr int kLayoutVersion = 2; +static constexpr int kLayoutVersion = 4; JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString &file) : QMainWindow(parent) { menuBar = new JFJochViewerMenu(this); @@ -449,6 +450,9 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString connect(this, &JFJochViewerWindow::setAutoForeground, viewer, &JFJochDiffractionImage::setAutoForeground); + connect(this, &JFJochViewerWindow::oneShotAutoForeground, + viewer, &JFJochDiffractionImage::oneShotAutoForeground); + // Processing jobs: run rugnux locally / copy a cluster command line, and surface // finished runs as switchable dataset snapshots. connect(processingJobsWindow, &JFJochProcessingJobsWindow::registerSnapshot, @@ -500,6 +504,21 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString addDockWidget(Qt::LeftDockWidgetArea, settingsDock); menuBar->AddDockEntry(settingsDock, "Settings"); + // File manager: an alternative to Settings in the same left-hand slot, tabbed together (the + // canonical Qt way to offer two panels one on top of the other without doubling the width) - most + // beamline users care about opening images, not reprocessing settings, so this one starts on top. + auto *fileBrowser = new JFJochViewerFileBrowser(this); + fileBrowserDock = new QDockWidget("File manager", this); + fileBrowserDock->setObjectName("fileBrowserDock"); + fileBrowserDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + fileBrowserDock->setWidget(fileBrowser); + addDockWidget(Qt::LeftDockWidgetArea, fileBrowserDock); + tabifyDockWidget(settingsDock, fileBrowserDock); + fileBrowserDock->raise(); + menuBar->AddDockEntry(fileBrowserDock, "File manager"); + connect(fileBrowser, &JFJochViewerFileBrowser::openDataset, + reading_worker, &JFJochImageReadingWorker::LoadFile); + connect(settingsPanel, &JFJochViewerSettingsDock::spotFindingChanged, reading_worker, &JFJochImageReadingWorker::UpdateSpotFindingSettings); connect(settingsPanel, &JFJochViewerSettingsDock::azintChanged, @@ -554,35 +573,6 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString processingDock->raise(); }); - // Image strip / hit feed: thumbnails of representative images, rendered off-thread; click opens. - // Stacked below the plots — both want vertical room, and the strip needs less of it. - auto *imageStrip = new JFJochViewerImageStrip(this); - imageStripDock = new QDockWidget("Image strip", this); - imageStripDock->setObjectName("imageStripDock"); - imageStripDock->setWidget(imageStrip); - addDockWidget(Qt::BottomDockWidgetArea, imageStripDock); - if (lastDatasetInfoDock) { - splitDockWidget(lastDatasetInfoDock, imageStripDock, Qt::Vertical); - resizeDocks({lastDatasetInfoDock, imageStripDock}, {260, 140}, Qt::Vertical); - } - menuBar->AddDockEntry(imageStripDock, "Image strip"); - connect(this, &JFJochViewerWindow::datasetReady, - imageStrip, &JFJochViewerImageStrip::datasetLoaded); - connect(reading_worker, &JFJochImageReadingWorker::fileOpened, - imageStrip, &JFJochViewerImageStrip::resetForNewFile); - connect(imageStrip, &JFJochViewerImageStrip::requestThumbnails, - reading_worker, &JFJochImageReadingWorker::RenderThumbnails); - connect(reading_worker, &JFJochImageReadingWorker::thumbnailReady, - imageStrip, &JFJochViewerImageStrip::thumbnailReady); - connect(imageStrip, &JFJochViewerImageStrip::imageSelected, - reading_worker, &JFJochImageReadingWorker::LoadImage); - connect(toolBarDisplay, &JFJochViewerToolbarDisplay::colorMapChanged, - reading_worker, &JFJochImageReadingWorker::SetThumbnailColorMap); - connect(side_panel, &JFJochViewerSidePanel::setFeatureColor, - reading_worker, &JFJochImageReadingWorker::SetThumbnailFeatureColor); - connect(side_panel, &JFJochViewerSidePanel::setSpotColor, - reading_worker, &JFJochImageReadingWorker::SetThumbnailSpotColor); - connect(menuBar, &JFJochViewerMenu::imageLayoutSelected, this, [this] { ApplyPerspective(Perspective::Image); }); connect(menuBar, &JFJochViewerMenu::processingLayoutSelected, this, @@ -603,6 +593,18 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString restoreGeometry(settings.value("geometry").toByteArray()); restoreState(settings.value("windowState").toByteArray(), kLayoutVersion); + // Display settings (color map, Auto, HDR) persist across sessions the same way the layout does. + const int colorMap = settings.value("colorMap", static_cast(ColorScaleEnum::Indigo)).toInt(); + viewer->setColorMap(colorMap); + toolBarDisplay->updateColorMap(colorMap); + + const bool autoForeground = settings.value("autoForeground", false).toBool(); + viewer->setAutoForeground(autoForeground); // emits autoForegroundChanged -> toolbar button state + + const bool hdrMode = settings.value("hdrMode", false).toBool(); + viewer->setHDRMode(hdrMode); + toolBarDisplay->updateHDRMode(hdrMode); + if (!file.isEmpty()) LoadFile(file, 0, 1, false); } @@ -615,11 +617,14 @@ JFJochViewerWindow::~JFJochViewerWindow() { } void JFJochViewerWindow::ApplyPerspective(Perspective p) { - // Image: just the image + inspector. Processing: also settings, dataset-info plots, jobs panel. + // Image: just the image + inspector (+ file manager, on top of its tab with Settings). + // Processing: also dataset-info plots and the jobs panel, with Settings raised instead. const bool processing = (p == Perspective::Processing); if (inspectorDock) inspectorDock->setVisible(true); - if (settingsDock) settingsDock->setVisible(processing); - if (imageStripDock) imageStripDock->setVisible(processing); + // Settings and the file manager stay tabbed together either way; only which one is on top changes. + if (settingsDock) settingsDock->setVisible(true); + if (fileBrowserDock) fileBrowserDock->setVisible(true); + (processing ? settingsDock : fileBrowserDock)->raise(); if (processingDock && !processing) processingDock->hide(); // shows only when a job starts for (auto *d : findChildren()) if (d->objectName().startsWith("datasetInfoDock")) @@ -631,6 +636,9 @@ void JFJochViewerWindow::closeEvent(QCloseEvent *event) { QSettings settings("PSI", "jfjoch_viewer"); settings.setValue("geometry", saveGeometry()); settings.setValue("windowState", saveState(kLayoutVersion)); + settings.setValue("colorMap", toolBarDisplay->colorMap()); + settings.setValue("autoForeground", toolBarDisplay->autoForeground()); + settings.setValue("hdrMode", toolBarDisplay->hdrMode()); QMainWindow::closeEvent(event); } @@ -701,7 +709,7 @@ void JFJochViewerWindow::keyPressEvent(QKeyEvent *event) { return; } if (event->key() == Qt::Key_A && ! event->isAutoRepeat()) { - emit setAutoForeground(true); + emit oneShotAutoForeground(); event->accept(); return; } diff --git a/viewer/JFJochViewerWindow.h b/viewer/JFJochViewerWindow.h index c9f8b734f..35aa6683c 100644 --- a/viewer/JFJochViewerWindow.h +++ b/viewer/JFJochViewerWindow.h @@ -41,8 +41,8 @@ private: QDockWidget *lastDatasetInfoDock = nullptr; // most recent dataset-info dock, for docking layout QDockWidget *inspectorDock = nullptr; // image inspector (former right-hand side panel) QDockWidget *settingsDock = nullptr; // inline MX/AzInt settings panel + QDockWidget *fileBrowserDock = nullptr; // data-root directory tree, tabbed with settingsDock QDockWidget *processingDock = nullptr; // processing jobs panel - QDockWidget *imageStripDock = nullptr; // thumbnail strip / hit feed QByteArray defaultLayoutState; // captured after construction, for "Reset layout" int datasetInfoCounter = 0; // gives each dataset-info dock a unique objectName @@ -88,5 +88,6 @@ signals: void LoadImageRequest(int64_t image_number, int64_t summation); void adjustForegroundButton(bool input); void setAutoForeground(bool val); + void oneShotAutoForeground(); }; diff --git a/viewer/charts/JFJochDatasetInfoChartView.cpp b/viewer/charts/JFJochDatasetInfoChartView.cpp index 1e65b1b85..92e3963dd 100644 --- a/viewer/charts/JFJochDatasetInfoChartView.cpp +++ b/viewer/charts/JFJochDatasetInfoChartView.cpp @@ -220,6 +220,11 @@ void JFJochDatasetInfoChartView::updateChart() { delete m_hoverLine; m_hoverLine = nullptr; } + if (m_hoverLineHorizontal) { + chart()->scene()->removeItem(m_hoverLineHorizontal); + delete m_hoverLineHorizontal; + m_hoverLineHorizontal = nullptr; + } #ifdef JFJOCH_USE_FFTW if (m_showFFT) { @@ -564,18 +569,25 @@ void JFJochDatasetInfoChartView::mouseMoveEvent(QMouseEvent *event) { const double fBin = m_fftFrequenciesHz[static_cast(bestIdx)]; const double amp = m_fftMagnitudes[static_cast(bestIdx)]; - // Map x position of that bin to scene coords for vertical line + // Map the bin's (frequency, amplitude) to scene coords for the crosshair. const QRectF plotArea = chart()->plotArea(); - const QPointF ptOnChart = chart()->mapToPosition(QPointF(fBin, 0.0), series); + const QPointF ptOnChart = chart()->mapToPosition(QPointF(fBin, amp), series); if (!m_hoverLine) { m_hoverLine = new QGraphicsLineItem; m_hoverLine->setPen(QPen(QColor(200, 0, 0, 150), 1.0)); chart()->scene()->addItem(m_hoverLine); } + if (!m_hoverLineHorizontal) { + m_hoverLineHorizontal = new QGraphicsLineItem; + m_hoverLineHorizontal->setPen(QPen(QColor(200, 0, 0, 150), 1.0)); + chart()->scene()->addItem(m_hoverLineHorizontal); + } m_hoverLine->setLine(QLineF(ptOnChart.x(), plotArea.top(), ptOnChart.x(), plotArea.bottom())); + m_hoverLineHorizontal->setLine(QLineF(plotArea.left(), ptOnChart.y(), + plotArea.right(), ptOnChart.y())); QString text = QString("f = %1 Hz, amplitude = %2") .arg(fBin, 0, 'g', 6) @@ -665,6 +677,24 @@ void JFJochDatasetInfoChartView::mouseMoveEvent(QMouseEvent *event) { m_hoverLine->setLine(QLineF(ptOnChart.x(), plotArea.top(), ptOnChart.x(), plotArea.bottom())); + // Horizontal crosshair at the hovered value, so a run of hovers reads off as a level (flat) or a + // drift (rising/falling) at a glance, not just point-by-point in the status bar. + if (std::isfinite(yv)) { + const QPointF ptOnChartY = + chart()->mapToPosition(QPointF(static_cast(idx), yv), series); + if (!m_hoverLineHorizontal) { + m_hoverLineHorizontal = new QGraphicsLineItem; + m_hoverLineHorizontal->setPen(QPen(QColor(200, 0, 0, 150), 1.0)); + chart()->scene()->addItem(m_hoverLineHorizontal); + } + m_hoverLineHorizontal->setLine(QLineF(plotArea.left(), ptOnChartY.y(), + plotArea.right(), ptOnChartY.y())); + } else if (m_hoverLineHorizontal) { + chart()->scene()->removeItem(m_hoverLineHorizontal); + delete m_hoverLineHorizontal; + m_hoverLineHorizontal = nullptr; + } + // Status bar text based on yv (bin mean in binned mode) QString text; if (m_yOneOverD) { @@ -711,6 +741,11 @@ void JFJochDatasetInfoChartView::leaveEvent(QEvent *event) { delete m_hoverLine; m_hoverLine = nullptr; } + if (m_hoverLineHorizontal) { + chart()->scene()->removeItem(m_hoverLineHorizontal); + delete m_hoverLineHorizontal; + m_hoverLineHorizontal = nullptr; + } m_hoverLoadTimer->stop(); m_hoverPendingIdx = -1; emit writeStatusBar(QString(), 0); diff --git a/viewer/charts/JFJochDatasetInfoChartView.h b/viewer/charts/JFJochDatasetInfoChartView.h index 98352e66f..0101bcd75 100644 --- a/viewer/charts/JFJochDatasetInfoChartView.h +++ b/viewer/charts/JFJochDatasetInfoChartView.h @@ -25,7 +25,8 @@ class JFJochDatasetInfoChartView : public QChartView { QPointer series = nullptr; QPointer currentSeries = nullptr; - QGraphicsLineItem *m_hoverLine = nullptr; + QGraphicsLineItem *m_hoverLine = nullptr; // vertical crosshair, at the hovered x + QGraphicsLineItem *m_hoverLineHorizontal = nullptr; // horizontal crosshair, at the hovered y QTimer *m_hoverLoadTimer = nullptr; int64_t m_hoverPendingIdx = -1; diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index dfd2e6e84..b0d14aaed 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include "JFJochSimpleImage.h" @@ -840,6 +841,31 @@ void JFJochDiffractionImage::roiScratchDrawn() { } void JFJochDiffractionImage::keyPressEvent(QKeyEvent *event) { + // QGraphicsView would otherwise consume these to scroll the viewport; claim them first so they + // navigate the dataset instead, via the same stepImage() the navigation toolbar already listens + // to. Home/End step by an effectively infinite amount, which stepImage's clamp turns into + // "jump to the first/last image". + switch (event->key()) { + case Qt::Key_Home: + emit stepImage(std::numeric_limits::min()); + event->accept(); + return; + case Qt::Key_End: + emit stepImage(std::numeric_limits::max()); + event->accept(); + return; + case Qt::Key_PageUp: + emit stepImage(1); + event->accept(); + return; + case Qt::Key_PageDown: + emit stepImage(-1); + event->accept(); + return; + default: + break; + } + if (event->key() == Qt::Key_Delete && image && !selected_roi_.isEmpty()) { ROIDefinition rois = image->Dataset().experiment.ROI().GetROIDefinition(); const std::string sel = selected_roi_.toStdString(); @@ -859,16 +885,22 @@ void JFJochDiffractionImage::keyPressEvent(QKeyEvent *event) { } +std::optional JFJochDiffractionImage::AutoForegroundValue() const { + if (!image) + return {}; + if (!hdr_mode) + return static_cast(image->GetAutoContrastValue()); + const auto val_range = image->ValidMinMax(); + if (!val_range.has_value()) + return {}; + return static_cast(val_range->second); +} + void JFJochDiffractionImage::UpdateForeground() { if (!image || !auto_fg) return; - if (hdr_mode) { - const auto val_range = image->ValidMinMax(); - if (val_range.has_value()) - foreground = val_range->second; - } else { - foreground = image->GetAutoContrastValue(); - } + if (const auto val = AutoForegroundValue()) + foreground = *val; emit foregroundChanged(foreground); } @@ -882,6 +914,7 @@ void JFJochDiffractionImage::setHDRMode(bool input) { void JFJochDiffractionImage::loadImage(std::shared_ptr in_image) { live_pending_ = false; // a live ROI edit (if any) has now been recomputed ring_cache_key_.clear(); // geometry may differ, re-trace the resolution rings + one_shot_auto_ = false; // a new image has its own auto value: `A` is a one-shot again if (in_image) { image = in_image; UpdateForeground(); @@ -902,6 +935,7 @@ void JFJochDiffractionImage::loadImage(std::shared_ptr void JFJochDiffractionImage::setAutoForeground(bool input) { auto_fg = input; + one_shot_auto_ = false; // whatever `A` did before, the next press starts as a one-shot again // If auto_foreground is not set, then view stays with the current settings till these are explicitly changed UpdateForeground(); RenderImage(); @@ -909,6 +943,25 @@ void JFJochDiffractionImage::setAutoForeground(bool input) { emit autoForegroundChanged(auto_fg); } +void JFJochDiffractionImage::oneShotAutoForeground() { + if (auto_fg) + return; // Auto already follows every image: there is nothing to apply, and nothing to undo + + if (one_shot_auto_) { + setAutoForeground(true); // second press on the same image: keep it on from now on + return; + } + + const auto val = AutoForegroundValue(); + if (!val) + return; + // Unlike a manual foreground change this leaves auto_fg alone (it is off here either way). + foreground = *val; + one_shot_auto_ = true; + ScheduleRenderImage(); + emit foregroundChanged(foreground); +} + void JFJochDiffractionImage::setResolutionRing(QVector v) { res_ring = v; ring_mode = RingMode::Manual; diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index 24e2dd1c1..0311227f0 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -91,6 +91,10 @@ private: void DrawCross(float x, float y, float size, float width, float z = 1); void UpdateForeground(); + // The foreground the current image suggests: the top of the valid range in HDR mode, the + // auto-contrast value otherwise. Empty when there is no image to take it from. + [[nodiscard]] std::optional AutoForegroundValue() const; + bool one_shot_auto_ = false; // `A` applied the auto value; pressing it again makes Auto permanent void leaveEvent(QEvent *event) override; std::shared_ptr image; @@ -125,6 +129,9 @@ public slots: void setSelectedROI(QString name); void loadImage(std::shared_ptr image); void setAutoForeground(bool input); + // Apply the auto-contrast value once, leaving Auto as it was; pressing it a second time on the + // same image switches Auto on for good (which keeps recomputing it on every subsequent image). + void oneShotAutoForeground(); void setResolutionRing(QVector v); void setResolutionRingMode(RingMode mode); diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index 72941a010..f9e87273e 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -143,16 +143,6 @@ void JFJochImage::setFeatureColor(QColor input) { void JFJochImage::wheelEvent(QWheelEvent *event) { if (!scene()) return; - // Alt+wheel steps through the dataset instead of zooming. The view does not know which image - // it shows, so the step is emitted and the navigation toolbar applies it. - if (event->modifiers() & Qt::AltModifier) { - const int delta = event->angleDelta().y(); - if (delta != 0) - emit stepImage(delta > 0 ? 1 : -1); - event->accept(); - return; - } - const double zoomFactor = 1.15; // Zoom factor // Get the position of the mouse in scene coordinates diff --git a/viewer/image_viewer/JFJochImage.h b/viewer/image_viewer/JFJochImage.h index afeb7a92c..089f0b9ae 100644 --- a/viewer/image_viewer/JFJochImage.h +++ b/viewer/image_viewer/JFJochImage.h @@ -237,7 +237,8 @@ signals: void hoverScenePos(QPointF scenePos); // A new frame has been rendered into Frame(). Follower views repaint on this. void frameRendered(); - // Alt+wheel asks for a move through the dataset: +1 one image forward, -1 one back. + // Asks for a move through the dataset (e.g. Home/End/PageUp/PageDown in a subclass' + // keyPressEvent): the navigation toolbar applies the step, clamped to the dataset's range. void stepImage(int steps); private slots: void onScroll(int value); diff --git a/viewer/toolbar/JFJochViewerToolbarDisplay.cpp b/viewer/toolbar/JFJochViewerToolbarDisplay.cpp index 7048192ad..e8a847f70 100644 --- a/viewer/toolbar/JFJochViewerToolbarDisplay.cpp +++ b/viewer/toolbar/JFJochViewerToolbarDisplay.cpp @@ -16,7 +16,7 @@ JFJochViewerToolbarDisplay::JFJochViewerToolbarDisplay(QWidget *parent) foreground_slider = new SliderPlusBox(1, MAX_SLIDER_NO_IMAGE, 1.0, 1, this, SliderPlusBox::ScaleType::Logarithmic); foreground_slider->setValue(10); - foreground_slider->setToolTip("White point of the colour map (image contrast)"); + foreground_slider->setToolTip("White point of the color map (image contrast)"); foreground_slider->setStyleSheet( "QSlider::groove:horizontal { height:6px; background:#F3D9D4; border-radius:3px; }" "QSlider::sub-page:horizontal { background:#FA7268; border-radius:3px; }" @@ -39,7 +39,7 @@ JFJochViewerToolbarDisplay::JFJochViewerToolbarDisplay(QWidget *parent) hdr_mode_button = makeToggle("HDR", "High dynamic range: show the full intensity range unclipped"); addWidget(hdr_mode_button); - addWidget(new QLabel(" Colour map ", this)); + addWidget(new QLabel(" Color map ", this)); // Initialize QComboBox with the options color_map_select = new QComboBox(this); @@ -87,6 +87,16 @@ void JFJochViewerToolbarDisplay::updateAutoForeground(bool val) { auto_foreground_button->setChecked(val); } +void JFJochViewerToolbarDisplay::updateColorMap(int val) { + QSignalBlocker blocker(color_map_select); + color_map_select->setCurrentIndex(val); +} + +void JFJochViewerToolbarDisplay::updateHDRMode(bool val) { + QSignalBlocker blocker(hdr_mode_button); + hdr_mode_button->setChecked(val); +} + void JFJochViewerToolbarDisplay::HDRModeButtonPressed() { emit setHDRMode(hdr_mode_button->isChecked()); } diff --git a/viewer/toolbar/JFJochViewerToolbarDisplay.h b/viewer/toolbar/JFJochViewerToolbarDisplay.h index 673fd39b0..831bf5ba1 100644 --- a/viewer/toolbar/JFJochViewerToolbarDisplay.h +++ b/viewer/toolbar/JFJochViewerToolbarDisplay.h @@ -24,6 +24,11 @@ class JFJochViewerToolbarDisplay : public QToolBar { public: JFJochViewerToolbarDisplay(QWidget *parent = nullptr); + // Current display settings, for persisting them across sessions (see JFJochViewerWindow). + [[nodiscard]] int colorMap() const { return color_map_select->currentIndex(); } + [[nodiscard]] bool autoForeground() const { return auto_foreground_button->isChecked(); } + [[nodiscard]] bool hdrMode() const { return hdr_mode_button->isChecked(); } + signals: void setForeground(float val); void colorMapChanged(int val); @@ -33,6 +38,8 @@ signals: public slots: void updateForeground(float val); void updateAutoForeground(bool val); + void updateColorMap(int val); + void updateHDRMode(bool val); void imageLoaded(std::shared_ptr image); private slots: void foregroundSet(double val); diff --git a/viewer/widgets/JFJochViewerFileBrowser.cpp b/viewer/widgets/JFJochViewerFileBrowser.cpp new file mode 100644 index 000000000..c78aacf75 --- /dev/null +++ b/viewer/widgets/JFJochViewerFileBrowser.cpp @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "JFJochViewerFileBrowser.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(sourceModel()); + if (fs && fs->isDir(fs->index(row, 0, parent))) + return true; + return QSortFilterProxyModel::filterAcceptsRow(row, parent); + } + }; +} + +QString JFJochViewerFileBrowser::DefaultRoot() { + // Site default (e.g. a beamline launcher setting it to /sls/mx/data/p), then whatever + // root was last browsed to (so re-launching without the variable set doesn't start over from + // scratch), 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; + + return QDir::homePath(); +} + +JFJochViewerFileBrowser::JFJochViewerFileBrowser(QWidget *parent) : QWidget(parent) { + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(4, 4, 4, 4); + + 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 among + // files. The many _data_NNNNNN.h5 shards of a dataset are not meant to be opened directly. + model_->setNameFilters({"*_master.h5", "*_process.h5"}); + 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::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); +} diff --git a/viewer/widgets/JFJochViewerFileBrowser.h b/viewer/widgets/JFJochViewerFileBrowser.h new file mode 100644 index 000000000..aede76eb4 --- /dev/null +++ b/viewer/widgets/JFJochViewerFileBrowser.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include +#include + +class QLineEdit; +class QTreeView; +class QModelIndex; + +// Left-side dock alternative to the Settings/reprocessing panel: a directory tree rooted at a +// configurable data root, filtered down to subdirectories and *_master.h5 / *_process.h5 files, for +// browsing to and opening a dataset without caring about reprocessing. Backed by QFileSystemModel, +// which only lists a directory once it is expanded - so a root with hundreds of sibling directories +// (e.g. an SLS beamline's /sls/mx/data/) costs one readdir at each level actually opened, never a +// recursive scan of the whole tree. +class JFJochViewerFileBrowser : public QWidget { + Q_OBJECT + + QLineEdit *root_edit_; + QLineEdit *filter_edit_; + QTreeView *tree_; + QFileSystemModel *model_; + QSortFilterProxyModel *proxy_; + + void SetRoot(const QString &path); + [[nodiscard]] static QString DefaultRoot(); + +private slots: + void onBrowseClicked(); + void onRootEditingFinished(); + void onDoubleClicked(const QModelIndex &index); + +signals: + // Mirrors JFJochViewerMenu::fileOpenSelected, so the window wires it to LoadFile the same way. + void openDataset(const QString &filename, qint64 image_number, qint64 summation, bool retry); + +public: + explicit JFJochViewerFileBrowser(QWidget *parent = nullptr); +}; diff --git a/viewer/widgets/JFJochViewerImageStatistics.cpp b/viewer/widgets/JFJochViewerImageStatistics.cpp index da6265d84..d10c11a6c 100644 --- a/viewer/widgets/JFJochViewerImageStatistics.cpp +++ b/viewer/widgets/JFJochViewerImageStatistics.cpp @@ -90,6 +90,7 @@ JFJochViewerImageStatistics::JFJochViewerImageStatistics(QWidget *parent) : QWid QFormLayout* layout = new QFormLayout(this); dataset_name = new QLabel(this); + dataset_name->setWordWrap(true); layout->addRow(new QLabel("Dataset:"), dataset_name); detector_name = new QLabel(this); @@ -183,7 +184,12 @@ void JFJochViewerImageStatistics::loadImage(std::shared_ptrDataset().experiment; - text = QString("%1").arg(QString::fromStdString(exp.GetFilePrefix())); + // Word-wrap alone would break wherever it likes (mid-directory-name); a zero-width space after + // every "/" gives it a preferred break point there, so a long path wraps like a path and only + // falls back to breaking a single component if that component alone doesn't fit. + QString path = QString::fromStdString(exp.GetFilePrefix()); + path.replace('/', QString("/") + QChar(0x200B)); // U+200B ZERO WIDTH SPACE, a break opportunity + text = QString("%1").arg(path); dataset_name->setText(text); dataset_name->setToolTip(QString("Collection data: %1
Beam center: %2 %3 pxl
Detector distance: %4 mm
Energy: %5 eV
Wavelength: %6 Å") .arg(QString::fromStdString(utc_to_local_human_readable(image->Dataset().arm_date))) diff --git a/viewer/widgets/JFJochViewerImageStrip.cpp b/viewer/widgets/JFJochViewerImageStrip.cpp deleted file mode 100644 index 1c8457150..000000000 --- a/viewer/widgets/JFJochViewerImageStrip.cpp +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute -// SPDX-License-Identifier: GPL-3.0-only - -#include "JFJochViewerImageStrip.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -JFJochViewerImageStrip::JFJochViewerImageStrip(QWidget *parent) : QWidget(parent) { - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(4, 2, 4, 2); - - auto *controls = new QHBoxLayout(); - controls->addWidget(new QLabel("Show", this)); - mode_ = new QComboBox(this); - mode_->addItem("Evenly spaced", 0); - mode_->addItem("Most spots", 1); - mode_->addItem("Indexed", 2); - mode_->addItem("Resolution", 3); // representatives across the resolution range - mode_->addItem("Background", 4); // representatives across the background range - controls->addWidget(mode_); - spots_ = new QCheckBox("Spots", this); - spots_->setChecked(true); - spots_->setToolTip("Overlay found spots — a real pattern reads like a constellation"); - controls->addWidget(spots_); - auto *refresh = new QToolButton(this); - refresh->setIcon(style()->standardIcon(QStyle::SP_BrowserReload)); - refresh->setAutoRaise(true); - refresh->setCursor(Qt::PointingHandCursor); - refresh->setToolTip("Re-roll the representative selection"); - controls->addWidget(refresh); - controls->addStretch(); - layout->addLayout(controls); - connect(refresh, &QToolButton::clicked, this, [this] { Rebuild(); }); - - scroll_ = new QScrollArea(this); - scroll_->setWidgetResizable(true); - scroll_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scroll_->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scroll_->setMinimumHeight(48); // let the dock shrink; thumbnails scale to fit - auto *stripWidget = new QWidget(scroll_); - strip_ = new QHBoxLayout(stripWidget); - strip_->setContentsMargins(0, 0, 0, 0); - strip_->addStretch(); - scroll_->setWidget(stripWidget); - layout->addWidget(scroll_); - - connect(mode_, &QComboBox::currentIndexChanged, this, [this] { Rebuild(); }); - connect(spots_, &QCheckBox::toggled, this, [this] { Rebuild(); }); -} - -void JFJochViewerImageStrip::datasetLoaded(std::shared_ptr dataset) { - // Store the latest dataset (metrics may update during live sync / re-analysis) but do NOT - // rebuild here — that would refetch thumbnails on every HTTP image update. Rebuilding happens - // on file open (resetForNewFile, which fires just after this) and on mode / Spots / Refresh. - dataset_ = std::move(dataset); -} - -void JFJochViewerImageStrip::resetForNewFile() { - Rebuild(); // a new file/stream opened; dataset_ was just set by datasetLoaded -} - -void JFJochViewerImageStrip::thumbnailReady(qint64 image_number, QImage thumb) { - auto it = slots_.find(image_number); - if (it != slots_.end()) - it.value()->setIcon(QPixmap::fromImage(thumb)); -} - -QVector JFJochViewerImageStrip::ComputeRepresentatives() const { - QVector result; - if (!dataset_) - return result; - const int64_t total = dataset_->experiment.GetImageNum(); - if (total <= 0) - return result; - const int N = static_cast(std::min(8, total)); - const int mode = mode_->currentIndex(); - auto *rng = QRandomGenerator::global(); - - // Pick one random image from each of N equal bins over an ordered candidate list. Stochastic, - // so Refresh re-rolls a fresh set and deterministic-spacing artefacts are avoided. - auto binnedRandom = [&](const std::vector &candidates) { - const size_t M = candidates.size(); - if (M == 0) - return; - const int n = static_cast(std::min(N, M)); - for (int i = 0; i < n; ++i) { - const size_t lo = static_cast(i) * M / n; - size_t hi = static_cast(i + 1) * M / n; - if (hi <= lo) hi = lo + 1; - result.push_back(candidates[lo + rng->bounded(static_cast(hi - lo))]); - } - }; - - // Image indices ordered by a metric (finite values only), low to high. - auto orderedByMetric = [](const std::vector &metric) { - std::vector> vals; - for (size_t i = 0; i < metric.size(); ++i) - if (std::isfinite(metric[i])) - vals.push_back({metric[i], static_cast(i)}); - std::sort(vals.begin(), vals.end()); - std::vector idx; - idx.reserve(vals.size()); - for (const auto &v : vals) idx.push_back(v.second); - return idx; - }; - - std::vector allIndices(total); - std::iota(allIndices.begin(), allIndices.end(), 0); - - const auto &sc = dataset_->spot_count; - const auto &sci = dataset_->spot_count_indexed; - - if (mode == 1 && !sc.empty()) { // most spots (deterministic top-N) - std::vector idx(sc.size()); - std::iota(idx.begin(), idx.end(), 0); - const size_t n = std::min(N, idx.size()); - std::partial_sort(idx.begin(), idx.begin() + n, idx.end(), - [&](int64_t a, int64_t b) { return sc[a] > sc[b]; }); - idx.resize(n); - std::sort(idx.begin(), idx.end()); - for (int64_t i : idx) result.push_back(i); - } else if (mode == 2 && !sci.empty()) { // indexed images, spaced random - std::vector indexed; - for (size_t i = 0; i < sci.size(); ++i) - if (sci[i] > 0) indexed.push_back(static_cast(i)); - binnedRandom(indexed.empty() ? allIndices : indexed); - } else if (mode == 3 && !dataset_->resolution_estimate.empty()) { - binnedRandom(orderedByMetric(dataset_->resolution_estimate)); - } else if (mode == 4 && !dataset_->bkg_estimate.empty()) { - binnedRandom(orderedByMetric(dataset_->bkg_estimate)); - } else { // evenly spaced (random within each bin) - binnedRandom(allIndices); - } - return result; -} - -void JFJochViewerImageStrip::Rebuild() { - // Clear the strip (keep the trailing stretch). - slots_.clear(); - while (strip_->count() > 1) { - QLayoutItem *item = strip_->takeAt(0); - if (item->widget()) - item->widget()->deleteLater(); - delete item; - } - if (!dataset_) - return; - - const QVector reps = ComputeRepresentatives(); - int pos = 0; - for (qint64 n : reps) { - auto *btn = new QToolButton(this); - btn->setToolButtonStyle(Qt::ToolButtonIconOnly); // the image number is drawn in the bitmap - btn->setAutoRaise(true); - btn->setCursor(Qt::PointingHandCursor); - btn->setToolTip(QStringLiteral("Open image %1").arg(n + 1)); - connect(btn, &QToolButton::clicked, this, [this, n] { emit imageSelected(n, 1); }); - strip_->insertWidget(pos++, btn); - slots_[n] = btn; - } - UpdateThumbnailSize(); - emit requestThumbnails(reps, spots_->isChecked()); -} - -void JFJochViewerImageStrip::UpdateThumbnailSize() { - if (!scroll_) - return; - // Scale the thumbnails to the available height so the strip never needs more room than it has. - const int h = std::clamp(scroll_->viewport()->height() - 4, 40, 240); - for (auto *btn : std::as_const(slots_)) - btn->setIconSize(QSize(h, h)); -} - -void JFJochViewerImageStrip::resizeEvent(QResizeEvent *event) { - QWidget::resizeEvent(event); - UpdateThumbnailSize(); -} diff --git a/viewer/widgets/JFJochViewerImageStrip.h b/viewer/widgets/JFJochViewerImageStrip.h deleted file mode 100644 index f1b5404b4..000000000 --- a/viewer/widgets/JFJochViewerImageStrip.h +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute -// SPDX-License-Identifier: GPL-3.0-only - -#pragma once - -#include -#include -#include - -#include "../../reader/JFJochReaderDataset.h" - -class QComboBox; -class QCheckBox; -class QHBoxLayout; -class QToolButton; -class QScrollArea; -class QResizeEvent; - -// A strip of thumbnails for a handful of representative images (evenly spaced, most spots, or -// indexed), optionally overlaid with spots so a real pattern reads like a constellation. Clicking -// a thumbnail opens that image. Thumbnails are rendered off-thread by the reading worker. -class JFJochViewerImageStrip : public QWidget { - Q_OBJECT -public: - explicit JFJochViewerImageStrip(QWidget *parent = nullptr); - -public slots: - void datasetLoaded(std::shared_ptr dataset); - void thumbnailReady(qint64 image_number, QImage thumb); - void resetForNewFile(); // a new file/stream opened: rebuild once, then stay put on live updates - -signals: - void requestThumbnails(QVector image_numbers, bool show_spots); - void imageSelected(int64_t image_number, int64_t summation); - -protected: - void resizeEvent(QResizeEvent *event) override; - -private: - std::shared_ptr dataset_; - QComboBox *mode_ = nullptr; - QCheckBox *spots_ = nullptr; - QScrollArea *scroll_ = nullptr; - QHBoxLayout *strip_ = nullptr; - QMap slots_; // image number -> its thumbnail button - - QVector ComputeRepresentatives() const; - void Rebuild(); - void UpdateThumbnailSize(); // scale thumbnails to the available dock height -}; diff --git a/viewer/windows/JFJochMouseShortcutsWindow.cpp b/viewer/windows/JFJochMouseShortcutsWindow.cpp index 5793ec891..21cf67e91 100644 --- a/viewer/windows/JFJochMouseShortcutsWindow.cpp +++ b/viewer/windows/JFJochMouseShortcutsWindow.cpp @@ -17,9 +17,10 @@ namespace { {"Wheel", "Zoom in / out, centred on the cursor"}, {"Shift + wheel", "Move the foreground (upper contrast limit) in linear steps"}, {"Ctrl + wheel", "Move the foreground in multiplicative steps (×1.15 per notch)"}, - {"Alt + wheel", "Step through the dataset, one image per notch (up = next image)"}, {"F held + wheel", "Same as Shift + wheel, for as long as F is held"}, - {"A", "Switch on the automatic foreground"}, + {"A", "Apply auto-contrast once; press again to switch on continuous Auto"}, + {"Home / End", "Jump to the first / last image in the dataset"}, + {"Page Up / Page Down", "Step one image forward / back"}, {"Hover", "Status bar shows the pixel position, its value and the resolution"}, {"Drag", "Pan the image"}, {"Shift + drag", "Draw a rectangular ROI"}, @@ -55,9 +56,6 @@ namespace { html += "" + row.action + "" + row.effect + ""; html += ""; } - html += "

Note: some window managers take Alt-modified mouse events for themselves " - "before the application sees them. That is a window-manager setting, not something " - "the viewer can take back.

"; html += ""; return html; }