Files
Jungfraujoch/viewer/windows/JFJochProcessingJobsWindow.cpp
T
leonarski_fandClaude Opus 4.8 a7c1560dcb viewer: toolbar rework — icons, prominent scrubber, HTTP states (g–k)
Complete pass over both toolbars (review items g–k), closing the toolbar topic.

New ToolbarIcons: crisp QPainter vector glyphs (Qt SVG is not a build dep), with
white "on" variants for toggles and a shared flat button style (coral hover,
navy checked).

Navigation toolbar, laid out source-first:
- Open (3.5" diskette) · HTTP sync · Movie (reel-on-top camera, distinct from
  the next/play triangle) ║ the scrub slider, which expands to fill the bar
  (coral track, navy handle) so it is the obvious way to move across the data ║
  precise navigation: first/prev/[number]/next/last, Jump, Sum.
- Open and HTTP-sync reach the file / connect dialogs (JFJochViewerMenu
  openSelected and openHttpSelected, now public).
- HTTP-sync has three states via a status dot (grey disconnected / green live /
  amber frozen); clicking with no live source attached opens the connect dialog.
  Removed the separate "Reanalyze" toggle.

Display toolbar:
- Styled foreground slider (matching), Auto/HDR as styled text toggles.

Hero buttons:
- "Reanalyze image" is now a toggle (worker ReanalyzeImages: run now + keep
  re-analysing on image/settings/processing changes; coral = active).

Processing dock:
- Drop the "New job…" toolbar action (the hero button drives it) and parent the
  job dialog to the main window instead of the dock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:26:15 +02:00

457 lines
18 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochProcessingJobsWindow.h"
#include "../../process/JFJochProcessCommandLine.h"
#include <QApplication>
#include <QCheckBox>
#include <QClipboard>
#include <QComboBox>
#include <QDateTime>
#include <QDialog>
#include <QFont>
#include <QDialogButtonBox>
#include <QFileInfo>
#include <QFormLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QProgressBar>
#include <QPushButton>
#include <QSpinBox>
#include <QStackedWidget>
#include <QTableWidget>
#include <QToolBar>
#include <QVBoxLayout>
#include <thread>
namespace {
// Status (the progress bar) is column 2 so it stays visible in the narrow dock.
enum Column { COL_NAME = 0, COL_STATUS, COL_STARTED, COL_MODE, COL_IMAGES, COL_INDEX, COL_CELL, COL_COUNT };
int default_threads() {
const unsigned hc = std::thread::hardware_concurrency();
return hc == 0 ? 4 : static_cast<int>(hc);
}
QTableWidgetItem *fixedItem(const QString &text) { // a non-editable cell
auto *cell = new QTableWidgetItem(text);
cell->setFlags(cell->flags() & ~Qt::ItemIsEditable);
return cell;
}
}
JFJochProcessingJobsWindow::JFJochProcessingJobsWindow(JFJochImageReadingWorker *worker, QWidget *parent)
: QWidget(parent), worker_(worker) {
setWindowTitle("Processing");
controller_ = new JFJochProcessController(this);
connect(controller_, &JFJochProcessController::phaseChanged, this, &JFJochProcessingJobsWindow::onPhase);
connect(controller_, &JFJochProcessController::progress, this, &JFJochProcessingJobsWindow::onProgress);
connect(controller_, &JFJochProcessController::finished, this, &JFJochProcessingJobsWindow::onFinished);
connect(controller_, &JFJochProcessController::failed, this, &JFJochProcessingJobsWindow::onFailed);
connect(controller_, &JFJochProcessController::liveDataset, this, &JFJochProcessingJobsWindow::liveDataset);
toolbar_ = new QToolBar("Jobs", this);
toolbar_->setMovable(false);
// "New job" is launched by the "Reanalyze dataset" hero button, not from this dock's toolbar.
toolbar_->addAction("Cancel", this, &JFJochProcessingJobsWindow::cancelJob);
toolbar_->addAction("Remove result", this, &JFJochProcessingJobsWindow::removeResult);
toolbar_->addSeparator();
toolbar_->addAction("Show selected", this, &JFJochProcessingJobsWindow::viewResults);
table_ = new QTableWidget(0, COL_COUNT, this);
table_->setMinimumHeight(60); // let the bottom dock shrink freely
table_->setHorizontalHeaderLabels({"Name", "Status", "Started", "Mode", "Images", "Index %", "Unit cell"});
table_->horizontalHeader()->setStretchLastSection(true);
table_->setSelectionBehavior(QAbstractItemView::SelectRows);
table_->setSelectionMode(QAbstractItemView::SingleSelection);
// Only the Name column is editable (per-item flags); editing it renames the run's legend label.
table_->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
connect(table_, &QTableWidget::itemChanged, this, [this](QTableWidgetItem *item) {
if (item->column() != COL_NAME)
return;
const int row = item->row();
if (row < 0 || row >= static_cast<int>(jobs_.size()) || item->text() == jobs_[row].label)
return;
jobs_[row].label = item->text();
emit renameRun(jobs_[row].id, jobs_[row].label);
});
// Double-click a row (other than its editable Name) to show that run in the plots/image.
connect(table_, &QTableWidget::cellDoubleClicked, this, [this](int row, int col) {
if (col == COL_NAME)
return; // double-clicking Name edits the label
if (row >= 0 && row < static_cast<int>(jobs_.size()) && jobs_[row].has_result)
emit activateSnapshot(jobs_[row].id);
});
auto *message = new QLabel("Dataset re-processing is currently available only in File mode.\n\n"
"Open a stored HDF5 file to run processing jobs.", this);
message->setAlignment(Qt::AlignCenter);
message->setWordWrap(true);
stack_ = new QStackedWidget(this);
stack_->addWidget(table_); // page 0
stack_->addWidget(message); // page 1
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(toolbar_);
layout->addWidget(stack_);
}
void JFJochProcessingJobsWindow::onHttpConnectionChanged(bool connected, QString addr) {
// Re-processing reads a stored HDF5 file; it is not available for the live HTTP stream.
stack_->setCurrentIndex(connected ? 1 : 0);
toolbar_->setEnabled(!connected);
}
int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec &spec) {
QDialog dlg(window()); // centre on the main window, not inside the processing dock
dlg.setWindowTitle("New processing job");
auto *mode = new QComboBox(&dlg);
mode->addItem("Full analysis", static_cast<int>(ProcessMode::FullAnalysis));
mode->addItem("Azimuthal integration", static_cast<int>(ProcessMode::AzimuthalIntegration));
auto *start_image = new QSpinBox(&dlg);
start_image->setRange(0, 1'000'000'000);
start_image->setValue(0);
auto *end_image = new QSpinBox(&dlg);
end_image->setRange(0, 1'000'000'000);
end_image->setValue(0);
end_image->setSpecialValueText("end"); // 0 => to the last image
auto *threads = new QSpinBox(&dlg);
threads->setRange(1, 256);
threads->setValue(default_threads());
auto *save = new QCheckBox("Save _process.h5", &dlg);
save->setChecked(true);
const QString stem = QFileInfo(inputs.file).completeBaseName().remove(QStringLiteral("_master"));
auto *prefix = new QLineEdit(QStringLiteral("%1_run%2").arg(stem).arg(job_counter_ + 1), &dlg);
auto *rotation = new QCheckBox("Rotation indexing (two-pass)", &dlg);
rotation->setChecked(inputs.experiment.GetGoniometer().has_value());
auto *rotation_images = new QSpinBox(&dlg); // images used to find the rotation lattice
rotation_images->setRange(2, 1'000'000);
rotation_images->setValue(spec.rotation_images); // carries the default (30)
auto *scaling = new QCheckBox("Scale && merge", &dlg);
auto sync_full = [&] {
const bool full = mode->currentData().toInt() == static_cast<int>(ProcessMode::FullAnalysis);
rotation->setEnabled(full);
scaling->setEnabled(full);
rotation_images->setEnabled(full && rotation->isChecked());
};
connect(mode, &QComboBox::currentIndexChanged, &dlg, sync_full);
connect(rotation, &QCheckBox::toggled, &dlg, sync_full);
sync_full();
auto *form = new QFormLayout;
form->addRow("Mode", mode);
form->addRow("Start image", start_image);
form->addRow("End image", end_image);
form->addRow("Threads", threads);
form->addRow(save);
form->addRow("Output prefix", prefix);
form->addRow(rotation);
form->addRow("Rotation images", rotation_images);
form->addRow(scaling);
int result = 0;
auto *run = new QPushButton("Run locally", &dlg);
auto *copy = new QPushButton("Copy command", &dlg);
auto *cancel = new QPushButton("Cancel", &dlg);
run->setDefault(true);
connect(run, &QPushButton::clicked, &dlg, [&] { result = 1; dlg.accept(); });
connect(copy, &QPushButton::clicked, &dlg, [&] { result = 2; dlg.accept(); });
connect(cancel, &QPushButton::clicked, &dlg, [&] { result = 0; dlg.reject(); });
auto *buttons = new QHBoxLayout;
buttons->addStretch();
buttons->addWidget(run);
buttons->addWidget(copy);
buttons->addWidget(cancel);
auto *layout = new QVBoxLayout(&dlg);
layout->addLayout(form);
layout->addLayout(buttons);
dlg.exec();
if (result != 0) {
spec.mode = static_cast<ProcessMode>(mode->currentData().toInt());
spec.start_image = start_image->value();
spec.end_image = end_image->value();
spec.threads = threads->value();
spec.save = save->isChecked();
spec.prefix = prefix->text();
spec.rotation = rotation->isEnabled() && rotation->isChecked();
spec.rotation_images = rotation_images->value();
spec.scaling = scaling->isEnabled() && scaling->isChecked();
}
return result;
}
ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const ReprocessingInputs &inputs) const {
ProcessConfig config;
config.mode = spec.mode;
config.nthreads = spec.threads;
config.start_image = spec.start_image;
config.end_image = spec.end_image > 0 ? spec.end_image : -1; // 0 => to the end
config.output_prefix = spec.save ? spec.prefix.toStdString() : std::string();
config.spot_finding = inputs.spot_finding;
if (spec.mode == ProcessMode::FullAnalysis) {
config.rotation_indexing = spec.rotation;
config.two_pass_rotation = true;
config.rotation_indexing_image_count = spec.rotation_images;
config.run_scaling = spec.scaling;
}
return config;
}
void JFJochProcessingJobsWindow::newJob() {
const ReprocessingInputs inputs = worker_->GetReprocessingInputs();
if (!inputs.valid) {
QMessageBox::information(this, "Processing", "Open a file first (processing is not available for live HTTP data).");
return;
}
JobSpec spec;
const int action = askJob(inputs, spec);
if (action == 0)
return;
const ProcessConfig config = buildConfig(spec, inputs);
// Rotation indexing must be enabled on the indexing settings too (config.rotation_indexing only
// drives the two-pass pre-pass); otherwise IndexAndRefine never builds a rotation indexer.
DiffractionExperiment experiment = inputs.experiment;
if (spec.mode == ProcessMode::FullAnalysis) {
auto idx = experiment.GetIndexingSettings();
idx.RotationIndexing(spec.rotation);
experiment.ImportIndexingSettings(idx);
}
if (action == 2) { // copy command line
const QString cmd = QString::fromStdString(
JFJochProcessCommandLine(config, experiment, inputs.file.toStdString()));
QApplication::clipboard()->setText(cmd);
QMessageBox::information(this, "Command line", cmd + "\n\n(copied to clipboard)");
return;
}
if (controller_->running()) {
QMessageBox::information(this, "Processing", "A job is already running; wait for it to finish or cancel it.");
return;
}
const bool full = spec.mode == ProcessMode::FullAnalysis;
const int run_number = ++job_counter_;
const QString label = QStringLiteral("%1 %2").arg(full ? "Full" : "AzInt").arg(run_number);
const QString id = QStringLiteral("run-%1").arg(run_number);
JobInfo info;
info.id = id;
info.label = label;
if (spec.save)
info.snapshot_path = QString::fromStdString(config.output_prefix) + "_process.h5";
const int row = table_->rowCount();
table_->insertRow(row);
jobs_.push_back(info); // jobs_[row] must exist before setItem(COL_NAME) fires itemChanged
const QString range_text = (spec.start_image == 0 && spec.end_image == 0)
? QStringLiteral("all")
: QStringLiteral("%1%2").arg(spec.start_image)
.arg(spec.end_image > 0 ? QString::number(spec.end_image) : QStringLiteral("end"));
const int expected = spec.end_image > spec.start_image ? spec.end_image - spec.start_image : 0;
table_->setItem(row, COL_NAME, new QTableWidgetItem(label)); // editable (default flags)
table_->setItem(row, COL_STARTED, fixedItem(QDateTime::currentDateTime().toString("HH:mm:ss")));
table_->setItem(row, COL_MODE, fixedItem(full ? "Full" : "AzInt"));
table_->setItem(row, COL_IMAGES, fixedItem(range_text));
table_->setItem(row, COL_STATUS, fixedItem("queued"));
table_->setItem(row, COL_INDEX, fixedItem("-"));
table_->setItem(row, COL_CELL, fixedItem("-"));
// A progress bar lives in the Status cell while the job runs; it shows the phase as text until
// image processing starts, then "<done> / <expected>" with the bar filling in the background.
running_bar_ = new QProgressBar(table_);
running_bar_->setAlignment(Qt::AlignCenter);
running_bar_->setRange(0, expected > 0 ? expected : 1);
running_bar_->setValue(0);
running_bar_->setFormat("queued");
table_->setCellWidget(row, COL_STATUS, running_bar_);
running_row_ = row;
job_timer_.start();
controller_->start(inputs.file, experiment, inputs.pixel_mask, config);
emit writeStatusBar("Started processing job " + label);
}
void JFJochProcessingJobsWindow::cancelJob() {
if (controller_->running()) {
controller_->cancel();
emit writeStatusBar("Cancelling processing job…");
}
}
void JFJochProcessingJobsWindow::removeResult() {
const int row = table_->currentRow();
if (row < 0 || row >= static_cast<int>(jobs_.size()))
return;
if (jobs_[row].id == "Original")
return; // the original file is always kept
if (row == running_row_) {
QMessageBox::information(this, "Processing", "Cancel the running job before removing it.");
return;
}
emit removeRun(jobs_[row].id); // drops the snapshot from the reader
QSignalBlocker block(table_); // row removal must not look like a rename
table_->removeRow(row);
jobs_.erase(jobs_.begin() + row);
if (running_row_ > row)
--running_row_;
}
void JFJochProcessingJobsWindow::clearJobs() {
if (controller_->running())
controller_->cancel();
emit liveDataset(nullptr);
QSignalBlocker block(table_);
table_->setRowCount(0);
jobs_.clear();
job_counter_ = 0;
running_row_ = -1;
running_bar_ = nullptr;
addOriginalRow();
}
void JFJochProcessingJobsWindow::addOriginalRow() {
// The file's own data, listed as the first run so it can be shown like any reprocessing run.
JobInfo info;
info.id = "Original";
info.label = "Original";
info.has_result = true;
const int row = table_->rowCount();
table_->insertRow(row);
jobs_.push_back(info);
QSignalBlocker block(table_);
table_->setItem(row, COL_NAME, fixedItem("Original")); // reserved name, not editable
table_->setItem(row, COL_STATUS, fixedItem(""));
table_->setItem(row, COL_STARTED, fixedItem("—"));
table_->setItem(row, COL_MODE, fixedItem("HDF5"));
table_->setItem(row, COL_IMAGES, fixedItem("all"));
table_->setItem(row, COL_INDEX, fixedItem("-"));
table_->setItem(row, COL_CELL, fixedItem("-"));
}
void JFJochProcessingJobsWindow::setActiveRun(QString active_id) {
QSignalBlocker block(table_);
for (int row = 0; row < static_cast<int>(jobs_.size()); row++) {
auto *item = table_->item(row, COL_NAME);
if (!item)
continue;
QFont f = item->font();
f.setBold(jobs_[row].id == active_id);
item->setFont(f);
}
}
void JFJochProcessingJobsWindow::viewResults() {
const int row = table_->currentRow();
if (row < 0 || row >= static_cast<int>(jobs_.size()))
return;
if (!jobs_[row].has_result) {
QMessageBox::information(this, "Processing", "This job has no saved results to view.");
return;
}
emit activateSnapshot(jobs_[row].id);
}
void JFJochProcessingJobsWindow::setStatus(int row, const QString &text) {
if (row >= 0 && row < table_->rowCount())
table_->item(row, COL_STATUS)->setText(text);
}
void JFJochProcessingJobsWindow::onPhase(QString phase) {
// The "Processing images" phase is shown by onProgress; other phases (first pass, scaling, ...)
// show their name as text over an empty bar.
if (!running_bar_ || phase == "Processing images")
return;
running_bar_->setRange(0, 1);
running_bar_->setValue(0);
running_bar_->setFormat(phase);
}
void JFJochProcessingJobsWindow::onProgress(quint64 done, quint64 total) {
if (!running_bar_)
return;
running_bar_->setRange(0, static_cast<int>(total));
running_bar_->setValue(static_cast<int>(done));
running_bar_->setFormat("%v / %m");
// Processing rate and ETA in the status bar.
if (job_timer_.isValid() && done > 0) {
const double secs = job_timer_.elapsed() / 1000.0;
if (secs > 0.0) {
const double hz = done / secs;
const double remaining = hz > 0.0 ? (total - done) / hz : 0.0;
emit writeStatusBar(QStringLiteral("Processing %1 Hz — ~%2 s remaining")
.arg(hz, 0, 'f', 1).arg(qRound(remaining)));
}
}
}
void JFJochProcessingJobsWindow::onFinished(ProcessResult result) {
emit liveDataset(nullptr); // the finished run takes over from the live overlay
const int row = running_row_;
running_row_ = -1;
if (row >= 0)
table_->removeCellWidget(row, COL_STATUS); // deletes running_bar_
running_bar_ = nullptr;
if (row < 0 || row >= static_cast<int>(jobs_.size()))
return;
setStatus(row, result.cancelled ? "cancelled" : "done");
if (result.indexing_rate.has_value())
table_->item(row, COL_INDEX)->setText(QStringLiteral("%1%").arg(result.indexing_rate.value() * 100.0, 0, 'f', 0));
if (result.consensus_cell.has_value()) {
const auto &c = result.consensus_cell.value();
table_->item(row, COL_CELL)->setText(
QStringLiteral("%1 %2 %3 %4 %5 %6")
.arg(c.a, 0, 'f', 1).arg(c.b, 0, 'f', 1).arg(c.c, 0, 'f', 1)
.arg(c.alpha, 0, 'f', 1).arg(c.beta, 0, 'f', 1).arg(c.gamma, 0, 'f', 1));
}
if (!result.cancelled && result.written_master_path.has_value() && !jobs_[row].snapshot_path.isEmpty()) {
jobs_[row].has_result = true;
emit registerSnapshot(jobs_[row].id, jobs_[row].label, jobs_[row].snapshot_path); // also activates it
}
emit writeStatusBar(result.cancelled ? "Processing cancelled" : "Processing finished");
}
void JFJochProcessingJobsWindow::onFailed(QString error) {
emit liveDataset(nullptr); // clear the live overlay
const int row = running_row_;
running_row_ = -1;
if (row >= 0)
table_->removeCellWidget(row, COL_STATUS); // deletes running_bar_
running_bar_ = nullptr;
setStatus(row, "failed");
QMessageBox::warning(this, "Processing failed", error);
}