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; }