// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "JFJochViewerSettingsDock.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../../common/Definitions.h" // MAX_SPOT_COUNT #include "SliderPlusBox.h" #include "NumberLineEdit.h" #include "PowderCalibrationWidget.h" #include "CollapsibleSection.h" #include "../../common/JFJochMath.h" #include "../../common/CUDAWrapper.h" #include "../../gemmi_gph/gemmi/symmetry.hpp" namespace { // What the goniometer fields offer when the file describes no sample motion, so picking a mode // on a still file starts from something sensible: the usual single rotation axis, and a raster // of the size beamlines commonly use. const char *DEFAULT_AXIS_NAME = "omega"; constexpr float DEFAULT_AXIS[3] = {-1.0f, 0.0f, 0.0f}; constexpr float DEFAULT_INCREMENT_DEG = 0.1f; constexpr float DEFAULT_GRID_N_FAST = 10.0f; constexpr float DEFAULT_GRID_STEP_UM = 20.0f; // Polarized fraction of a synchrotron beam - the same default a bare `rugnux` run applies. constexpr float DEFAULT_POLARIZATION = 0.99f; // A single diffraction frame (image) vs a stack of frames (dataset), drawn white so they read on // the navy "Analyze" hero buttons. QIcon FramesIcon(int frames) { const int S = 28; QPixmap pm(S, S); pm.fill(Qt::transparent); QPainter p(&pm); p.setRenderHint(QPainter::Antialiasing); p.setPen(QPen(Qt::white, 2.0)); p.setBrush(Qt::NoBrush); const double side = 13.0, step = 4.0; for (int i = frames - 1; i >= 0; --i) p.drawRoundedRect(QRectF(4 + i * step, 4 + i * step, side, side), 2.5, 2.5); p.setBrush(Qt::white); // a diffraction "spot" in the front frame p.drawEllipse(QPointF(4 + side / 2.0, 4 + side / 2.0), 2.0, 2.0); p.end(); return QIcon(pm); } } JFJochViewerSettingsDock::JFJochViewerSettingsDock(const SpotFindingSettings &spot, const IndexingSettings &indexing, const AzimuthalIntegrationSettings &azint, const BraggIntegrationSettings &bragg, const ScalingSettings &scaling, QWidget *parent) : QWidget(parent), spot_(spot), indexing_(indexing), azint_(azint), bragg_(bragg), scaling_(scaling), adaptive_min_pix_(!spot.min_pix_per_spot.has_value()), min_pix_value_(spot.min_pix_per_spot.value_or(2)) { auto *layout = new QVBoxLayout(this); // The two analysis actions sit on top of the panel. "Analyze image" is a toggle (re-analyse the // current frame now and on every change while armed); "Analyze dataset" launches a processing job // whose kind (MX vs azimuthal) is decided by the MX/AzInt toggle below — no separate switch. const QString heroStyle = "QPushButton { background-color:#1F3A5F; color:white; border:none; border-radius:3px;" " padding:5px 10px; } QPushButton:hover { background-color:#16314f; }" " QPushButton:checked { background-color:#FA7268; } QPushButton:disabled { background-color:#9aa6b3; }"; auto *analyzeImageBtn = new QPushButton(FramesIcon(1), " Analyze image", this); analyzeImageBtn->setCheckable(true); analyzeImageBtn->setStyleSheet(heroStyle); analyzeImageBtn->setToolTip("Re-analyse the current image now, and keep re-analysing on every" " image / settings change while active"); analyzeDatasetBtn_ = new QPushButton(FramesIcon(3), " Analyze dataset", this); analyzeDatasetBtn_->setStyleSheet(heroStyle); analyzeDatasetBtn_->setToolTip("Process the whole dataset (MX, azimuthal or calibration, per the toggle below)"); auto *analyzeRow = new QHBoxLayout(); analyzeRow->addWidget(analyzeImageBtn); analyzeRow->addWidget(analyzeDatasetBtn_); layout->addLayout(analyzeRow); layout->addSpacing(10); connect(analyzeImageBtn, &QPushButton::toggled, this, &JFJochViewerSettingsDock::reanalyzeImage); connect(analyzeDatasetBtn_, &QPushButton::clicked, this, [this] { emit analyzeDataset(mode_, powder_->Selection()); }); // Segmented MX / AzInt / Calib toggle: each community picks its page; pages never share a screen. // The page is also the mode "Analyze dataset" runs, so there is one control, not two. auto *mxButton = new QPushButton("MX", this); auto *azButton = new QPushButton("AzInt", this); auto *calibButton = new QPushButton("Calib", this); for (auto *b : {mxButton, azButton, calibButton}) { b->setCheckable(true); b->setStyleSheet("QPushButton:checked { background-color: #1F3A5F; color: white; }"); } mxButton->setChecked(true); auto *group = new QButtonGroup(this); group->setExclusive(true); group->addButton(mxButton, 0); group->addButton(azButton, 1); group->addButton(calibButton, 2); auto *toggleRow = new QHBoxLayout(); toggleRow->setSpacing(0); toggleRow->addWidget(mxButton); toggleRow->addWidget(azButton); toggleRow->addWidget(calibButton); auto *stack = new QStackedWidget(this); stack->addWidget(BuildMXPage()); stack->addWidget(new QWidget(stack)); // AzInt: the shared section below is the whole page stack->addWidget(BuildCalibrationPage()); connect(group, &QButtonGroup::idClicked, this, [this, stack](int id) { mode_ = id == 1 ? ProcessMode::AzimuthalIntegration : id == 2 ? ProcessMode::Calibration : ProcessMode::FullAnalysis; stack->setCurrentIndex(id); azintSection_->setVisible(id != 0); }); // Geometry is common to all three, so it lives above the toggle rather than per page. Azimuthal // integration is common to two of them - a calibration by rings integrates the run in azimuthal // sectors, and needs the same Q range and spacing - so it is shared the same way and hidden on MX. layout->addLayout(toggleRow); layout->addWidget(BuildGeometrySection()); azintSection_ = BuildAzIntSection(); azintSection_->setVisible(false); // MX is the page selected on start layout->addWidget(azintSection_); layout->addWidget(stack); layout->addStretch(); } void JFJochViewerSettingsDock::setHttpConnection(bool connected, QString) { analyzeDatasetBtn_->setEnabled(!connected); analyzeDatasetBtn_->setToolTip(connected ? "Dataset re-processing is only available for an open file, not a live HTTP stream" : "Process the whole dataset (MX, azimuthal or calibration, per the toggle below)"); } QWidget *JFJochViewerSettingsDock::BuildGeometrySection() { auto *section = new CollapsibleSection("Geometry", this); auto *geom = new QFormLayout(); geom->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); // fields fill the panel width energy_ = new NumberLineEdit(1.0, 200.0, 12.4, 4, "keV", this); distance_ = new NumberLineEdit(10.0, 5000.0, 100.0, 2, "mm", this); beamX_ = new NumberLineEdit(-20000.0, 20000.0, 0.0, 1, "px", this); beamY_ = new NumberLineEdit(-20000.0, 20000.0, 0.0, 1, "px", this); rot1_ = new NumberLineEdit(-180.0, 180.0, 0.0, 3, "°", this); rot2_ = new NumberLineEdit(-180.0, 180.0, 0.0, 3, "°", this); // Both are overwritten from the experiment as soon as a dataset is open; this is what they show // until then. polarizationOn_ = new QCheckBox("Polarization correction", this); polarizationOn_->setChecked(true); polarization_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_POLARIZATION, 3, "", this); const QString polarizationTip = "Polarized fraction of the beam in the horizontal plane (XDS FRACTION_OF_POLARIZATION): 0.99 " "for a synchrotron, 0 for an unpolarized source. Divides out the Lp modulation in both the " "azimuthal profile and the integrated Bragg intensities; unchecked = no correction at all."; polarizationOn_->setToolTip(polarizationTip); polarization_->setToolTip(polarizationTip); // The detector origin is the PONI (PyFAI) point; in MX (XDS-style) terms it is the beam origin, // which coincides with the beam center only when the detector is untilted. const QString beamTip = "Beam origin (XDS convention): the PONI point where the un-tilted beam meets the detector. " "Equals the beam center only when the detector tilt is zero."; beamX_->setToolTip(beamTip); beamY_->setToolTip(beamTip); const QString tiltTip = "Detector tilt about the two in-plane axes (PyFAI PONI rot1 / rot2), in degrees."; rot1_->setToolTip(tiltTip); rot2_->setToolTip(tiltTip); auto *beam = new QHBoxLayout(); beam->addWidget(beamX_); beam->addWidget(beamY_); auto *tilt = new QHBoxLayout(); tilt->addWidget(rot1_); tilt->addWidget(rot2_); auto *polarization = new QHBoxLayout(); polarization->addWidget(polarizationOn_); polarization->addWidget(polarization_, 1); geom->addRow("Photon energy", energy_); geom->addRow("Detector distance", distance_); geom->addRow("Beam origin", beam); geom->addRow("Detector tilt", tilt); geom->addRow("", polarization); for (auto *f : {energy_, distance_, beamX_, beamY_, rot1_, rot2_, polarization_}) connect(f, &NumberLineEdit::newValue, this, [this] { EmitExperiment(); }); connect(polarizationOn_, &QCheckBox::toggled, this, [this](bool on) { polarization_->setEnabled(on); EmitExperiment(); }); section->setContentLayout(geom); return section; } QWidget *JFJochViewerSettingsDock::BuildMXPage() { auto *page = new QWidget(this); auto *layout = new QVBoxLayout(page); layout->setContentsMargins(0, 0, 0, 0); // --- Unit cell + space group (new: no input existed before) --- auto *cellSection = new CollapsibleSection("Unit cell", page); auto *cell = new QFormLayout(); cell->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); cellKnown_ = new QCheckBox("Known unit cell", page); cell->addRow("", cellKnown_); cellA_ = new NumberLineEdit(1.0, 2000.0, 79.0, 3, "Å", page); cellB_ = new NumberLineEdit(1.0, 2000.0, 79.0, 3, "Å", page); cellC_ = new NumberLineEdit(1.0, 2000.0, 38.0, 3, "Å", page); cellAlpha_ = new NumberLineEdit(1.0, 179.0, 90.0, 2, "°", page); cellBeta_ = new NumberLineEdit(1.0, 179.0, 90.0, 2, "°", page); cellGamma_ = new NumberLineEdit(1.0, 179.0, 90.0, 2, "°", page); spaceGroup_ = new NumberLineEdit(0.0, 230.0, 0.0, 0, "", page); spaceGroup_->setToolTip("Space group number (1–230); 0 = unset."); spaceGroupName_ = new QLabel("—", page); auto *abc = new QHBoxLayout(); abc->addWidget(cellA_); abc->addWidget(cellB_); abc->addWidget(cellC_); auto *angles = new QHBoxLayout(); angles->addWidget(cellAlpha_); angles->addWidget(cellBeta_); angles->addWidget(cellGamma_); // Number and symbol split the line evenly, like the beam-origin row. auto *sgRow = new QHBoxLayout(); sgRow->addWidget(spaceGroup_, 1); sgRow->addWidget(spaceGroupName_, 1); cell->addRow("a, b, c", abc); cell->addRow("α, β, γ", angles); cell->addRow("Space group", sgRow); cellSection->setContentLayout(cell); layout->addWidget(cellSection); auto enableCellFields = [this](bool on) { for (auto *f : {cellA_, cellB_, cellC_, cellAlpha_, cellBeta_, cellGamma_, spaceGroup_}) f->setEnabled(on); }; enableCellFields(false); connect(cellKnown_, &QCheckBox::toggled, this, [this, enableCellFields](bool on) { enableCellFields(on); UpdateSpaceGroupName(); UpdateAlgorithmDescription(); // Auto resolves to FFBIDX vs FFT depending on a known cell EmitExperiment(); }); for (auto *f : {cellA_, cellB_, cellC_, cellAlpha_, cellBeta_, cellGamma_}) connect(f, &NumberLineEdit::newValue, this, [this] { EmitExperiment(); }); connect(spaceGroup_, &NumberLineEdit::newValue, this, [this] { UpdateSpaceGroupName(); EmitExperiment(); }); layout->addWidget(BuildGoniometerSection()); // --- Spot finding --- auto *spotSection = new CollapsibleSection("Spot finding", page); auto *spot = new QFormLayout(); spot->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto *snr = new SliderPlusBox(1.0, 10.0, 0.1, 1, page); snr->setValue(spot_.signal_to_noise_threshold); auto *count = new SliderPlusBox(0.0, 100.0, 1.0, 0, page); count->setValue(std::lround(spot_.photon_count_threshold)); auto *adaptive = new QCheckBox("Adaptive threshold (per-ring, auto)", page); adaptive->setChecked(spot_.adaptive_threshold); adaptive->setToolTip("Self-calibrating detection: the strong-pixel threshold is derived from each " "image's own per-resolution-ring noise, so one setting adapts across datasets. " "The signal/noise and photon-count settings are not used while this is on. " "Default for stills and rotation alike, as in rugnux."); auto *highResSpot = new SliderPlusBox(0.5, 5.0, 0.1, 1, page); highResSpot->setValue(spot_.high_resolution_limit.value_or(1.5f)); auto *autoHighResSpot = new QCheckBox("To detector edge", page); autoHighResSpot->setChecked(!spot_.high_resolution_limit.has_value()); autoHighResSpot->setToolTip("Find spots as far as the detector reaches, instead of clipping the " "detection at a fixed resolution. The high-resolution value is not used " "while this is on."); auto *minPix = new NumberLineEdit(1.0f, 50.0f, static_cast(min_pix_value_), 0, "px", page); auto *adaptiveMinPix = new QCheckBox("Adaptive min-pix (stills)", page); adaptiveMinPix->setChecked(adaptive_min_pix_); adaptiveMinPix->setToolTip("Choose the minimum pixels/spot per image: index the frame at min-pix " "3/2/1 and keep whichever maximises indexed count x indexed fraction. " "Stills only - a rotation dataset builds one lattice from all frames and " "always uses the fixed min-pixels/spot value below."); // The same bounds DatasetSettings::MaxSpotCount enforces - offering more than it accepts only got // the value rejected further down. auto *maxSpots = new NumberLineEdit(10.0f, static_cast(MAX_SPOT_COUNT), static_cast(max_spots_), 0, "", page); spot->addRow("", adaptive); spot->addRow("Signal/noise", snr); spot->addRow("Photon count", count); spot->addRow("", autoHighResSpot); spot->addRow("High resolution [Å]", highResSpot); spot->addRow("", adaptiveMinPix); spot->addRow("Min pixels/spot", minPix); spot->addRow("Max spots/image", maxSpots); spotSection->setContentLayout(spot); layout->addWidget(spotSection); connect(snr, &SliderPlusBox::valueChanged, this, [this](double v) { spot_.signal_to_noise_threshold = static_cast(v); EmitSpotFinding(); }); connect(count, &SliderPlusBox::valueChanged, this, [this](double v) { spot_.photon_count_threshold = std::llround(v); EmitSpotFinding(); }); connect(highResSpot, &SliderPlusBox::valueChanged, this, [this](double v) { spot_.high_resolution_limit = static_cast(v); EmitSpotFinding(); }); connect(minPix, &NumberLineEdit::newValue, this, [this, minPix] { min_pix_value_ = std::llround(minPix->value()); EmitSpotFinding(); }); connect(maxSpots, &NumberLineEdit::newValue, this, [this, maxSpots] { max_spots_ = std::llround(maxSpots->value()); EmitSpotFinding(); }); // The adaptive finder sets its own threshold from each image's noise, so the signal/noise and // photon-count sliders do nothing while it is on - grey them out to make that clear. auto syncAdaptiveEnabled = [snr, count](bool on) { snr->setEnabled(!on); count->setEnabled(!on); }; syncAdaptiveEnabled(spot_.adaptive_threshold); connect(adaptive, &QCheckBox::toggled, this, [this, syncAdaptiveEnabled](bool on) { spot_.adaptive_threshold = on; syncAdaptiveEnabled(on); EmitSpotFinding(); }); // An unset high-resolution limit (std::nullopt) means "to the detector edge", so the value is unused // while that is on - grey it out, as for the other automatic settings. auto syncHighResEnabled = [highResSpot](bool auto_on) { highResSpot->setEnabled(!auto_on); }; syncHighResEnabled(!spot_.high_resolution_limit.has_value()); connect(autoHighResSpot, &QCheckBox::toggled, this, [this, highResSpot, syncHighResEnabled](bool on) { if (on) spot_.high_resolution_limit = std::nullopt; else spot_.high_resolution_limit = static_cast(highResSpot->value()); syncHighResEnabled(on); EmitSpotFinding(); }); // The fixed min-pixels/spot field stays live even with adaptive min-pix on: it is what a rotation // dataset uses, and what stills fall back to when adaptive is switched off. connect(adaptiveMinPix, &QCheckBox::toggled, this, [this](bool on) { adaptive_min_pix_ = on; EmitSpotFinding(); }); // --- Indexing --- auto *idxSection = new CollapsibleSection("Indexing", page); auto *idx = new QFormLayout(); idx->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); algo_ = new QComboBox(page); algo_->addItem("Auto", static_cast(IndexingAlgorithmEnum::Auto)); algo_->addItem("FFBIDX (GPU, known cell)", static_cast(IndexingAlgorithmEnum::FFBIDX)); algo_->addItem("FFT (GPU, de-novo)", static_cast(IndexingAlgorithmEnum::FFT)); algo_->addItem("FFTW (CPU, de-novo)", static_cast(IndexingAlgorithmEnum::FFTW)); algo_->addItem("None", static_cast(IndexingAlgorithmEnum::None)); algo_->setCurrentIndex(algo_->findData(static_cast(indexing_.GetAlgorithm()))); algoDesc_ = new QLabel(page); algoDesc_->setWordWrap(true); algoDesc_->setStyleSheet("color: gray;"); auto *refine = new QComboBox(page); refine->addItem("None", static_cast(GeomRefinementAlgorithmEnum::None)); refine->addItem("Orientation only", static_cast(GeomRefinementAlgorithmEnum::OrientationOnly)); refine->addItem("Beam center + lattice", static_cast(GeomRefinementAlgorithmEnum::BeamCenter)); // "Flex" (the CLI's -r flex): tries all per-image refinements and keeps whichever indexes the most // spots — lets the pipeline decide. Named "Flex", not "multi", to avoid the CrystFEL sense of multi // (multi-lattice integration). refine->addItem("Flex (best per-image refinement)", static_cast(GeomRefinementAlgorithmEnum::Flex)); refine->setCurrentIndex(refine->findData(static_cast(indexing_.GetGeomRefinementAlgorithm()))); idx->addRow("Algorithm", algo_); idx->addRow("", algoDesc_); idx->addRow("Refinement", refine); idxSection->setContentLayout(idx); layout->addWidget(idxSection); connect(algo_, &QComboBox::currentIndexChanged, this, [this] { indexing_.Algorithm(static_cast(algo_->currentData().toInt())); UpdateAlgorithmDescription(); EmitSpotFinding(); }); connect(refine, &QComboBox::currentIndexChanged, this, [this, refine] { indexing_.GeomRefinementAlgorithm(static_cast(refine->currentData().toInt())); EmitSpotFinding(); }); UpdateAlgorithmDescription(); layout->addWidget(BuildBraggSection()); layout->addWidget(BuildScalingSection()); layout->addWidget(BuildReferenceSection()); layout->addStretch(); // anchor sections to the top so expanding an accordion grows downward return page; } QWidget *JFJochViewerSettingsDock::BuildGoniometerSection() { // How the sample moved between images, in the same three cases a file stores: a rotation axis // (/entry/sample/transformations/omega), a grid scan (/entry/sample/grid_scan), or neither. The // mode IS the rotation/stills switch, so there is no second "process as stills" control, and it // is not tied to what the file says: a still file can be given an axis or a grid, and a rotation // file can be processed as stills by choosing Still. auto *section = new CollapsibleSection("Goniometer", this); auto *form = new QFormLayout(); form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); // Exclusive, so radio buttons rather than checkboxes: a dataset is one of the three, never two. modeStill_ = new QRadioButton("Still", this); modeStill_->setToolTip("The sample does not move between images: every image is an independent " "still (per-frame indexing, fixed partiality)."); modeRotation_ = new QRadioButton("Rotation", this); modeRotation_->setToolTip("The sample rotates between images: rotation indexing, Ewald-crossing " "partiality and 3D rotation scaling."); modeGrid_ = new QRadioButton("Grid scan", this); modeGrid_->setToolTip("The sample translates between images on a raster. Processed as stills; the " "grid is what the dataset-info panel maps the per-image results onto."); auto *modes = new QButtonGroup(this); modes->setExclusive(true); for (auto *b : {modeStill_, modeRotation_, modeGrid_}) modes->addButton(b); modeStill_->setChecked(true); axisName_ = new QLineEdit(DEFAULT_AXIS_NAME, this); axisName_->setToolTip("Name of the rotated goniometer axis, as it is written to the output."); axisX_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_AXIS[0], 3, "", this); axisY_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_AXIS[1], 3, "", this); axisZ_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_AXIS[2], 3, "", this); const QString axisTip = "Rotation axis in the laboratory frame (x, y, z); normalised on use."; for (auto *f : {axisX_, axisY_, axisZ_}) f->setToolTip(axisTip); rotStart_ = new NumberLineEdit(-3600.0, 3600.0, 0.0, 3, "°", this); rotStart_->setToolTip("Angle of the first image."); rotIncrement_ = new NumberLineEdit(-10.0, 10.0, DEFAULT_INCREMENT_DEG, 3, "°", this); rotIncrement_->setToolTip("Angle rotated per image."); // Only the fast axis has a point count: the slow one is however many rows the images fill, which // is also why the file stores n_fast alone. The steps are signed - the sign is the direction the // scan runs in - so only zero is rejected. gridNFast_ = new NumberLineEdit(1.0, 100000.0, DEFAULT_GRID_N_FAST, 0, "", this); gridNFast_->setToolTip("Points along the fast axis. The number of slow rows follows from the " "number of images."); gridStepX_ = new NumberLineEdit(-10000.0, 10000.0, DEFAULT_GRID_STEP_UM, 1, "μm", this); gridStepY_ = new NumberLineEdit(-10000.0, 10000.0, DEFAULT_GRID_STEP_UM, 1, "μm", this); const QString stepTip = "Distance between neighbouring points, in x and y. Negative = the scan " "runs towards decreasing x / y."; gridStepX_->setToolTip(stepTip); gridStepY_->setToolTip(stepTip); gridVertical_ = new QCheckBox("Fast axis vertical", this); gridVertical_->setToolTip("The scan fills a column before moving to the next one (fast axis = y) " "instead of filling a row (fast axis = x)."); gridSnake_ = new QCheckBox("Snake scan", this); gridSnake_->setToolTip("Every second row/column is collected in the reverse direction (boustrophedon) " "instead of returning to the start of the next one."); gridSummary_ = new QLabel(this); gridSummary_->setStyleSheet("color: gray;"); auto *axis = new QHBoxLayout(); axis->addWidget(axisX_); axis->addWidget(axisY_); axis->addWidget(axisZ_); auto *step = new QHBoxLayout(); step->addWidget(gridStepX_); step->addWidget(gridStepY_); auto *gridFlags = new QHBoxLayout(); gridFlags->addWidget(gridVertical_); gridFlags->addWidget(gridSnake_); form->addRow("", modeStill_); form->addRow("", modeRotation_); form->addRow("Axis name", axisName_); form->addRow("Axis vector", axis); form->addRow("Start angle", rotStart_); form->addRow("Increment", rotIncrement_); form->addRow("", modeGrid_); form->addRow("Points (fast)", gridNFast_); form->addRow("Step x, y", step); form->addRow("", gridFlags); form->addRow("", gridSummary_); section->setContentLayout(form); EnableRotationFields(false); // datasetLoaded picks the mode the file describes EnableGridFields(false); // One handler for the three: whichever is now on decides which fields are live, what the // experiment carries, and whether the run is a rotation or stills one. for (auto *b : {modeStill_, modeRotation_, modeGrid_}) connect(b, &QRadioButton::toggled, this, [this](bool on) { if (!on) return; // the button being switched off reports first; act once, on the new mode EnableRotationFields(modeRotation_->isChecked()); EnableGridFields(modeGrid_->isChecked()); UpdateGridSummary(); ApplyProcessingMode(); EmitExperiment(); }); connect(axisName_, &QLineEdit::editingFinished, this, [this] { EmitExperiment(); }); for (auto *f : {axisX_, axisY_, axisZ_, rotStart_, rotIncrement_}) connect(f, &NumberLineEdit::newValue, this, [this] { EmitExperiment(); }); for (auto *f : {gridNFast_, gridStepX_, gridStepY_}) connect(f, &NumberLineEdit::newValue, this, [this] { UpdateGridSummary(); EmitExperiment(); }); for (auto *b : {gridVertical_, gridSnake_}) connect(b, &QCheckBox::toggled, this, [this] { UpdateGridSummary(); EmitExperiment(); }); return section; } void JFJochViewerSettingsDock::EnableRotationFields(bool on) { axisName_->setEnabled(on); for (auto *f : {axisX_, axisY_, axisZ_, rotStart_, rotIncrement_}) f->setEnabled(on); } void JFJochViewerSettingsDock::EnableGridFields(bool on) { for (auto *f : {gridNFast_, gridStepX_, gridStepY_}) f->setEnabled(on); gridVertical_->setEnabled(on); gridSnake_->setEnabled(on); } void JFJochViewerSettingsDock::UpdateGridSummary() { const auto grid = GridScanFromFields(); gridSummary_->setText(grid ? QStringLiteral("%1 x %2 points for %3 images") .arg(grid->GetGridSizeX_step()) .arg(grid->GetGridSizeY_step()) .arg(experiment_.GetImageNum()) : QString()); } std::optional JFJochViewerSettingsDock::GoniometerFromFields() const { if (!modeRotation_->isChecked()) return std::nullopt; // GoniometerAxis throws on an empty name or a zero-length axis, and an exception thrown out of a // widget signal aborts the viewer - fall back to the defaults for a half-typed entry. std::string name = axisName_->text().trimmed().toStdString(); if (name.empty()) name = DEFAULT_AXIS_NAME; Coord axis(static_cast(axisX_->value()), static_cast(axisY_->value()), static_cast(axisZ_->value())); if (axis.Length() == 0.0f) axis = Coord(DEFAULT_AXIS); GoniometerAxis gonio(name, static_cast(rotStart_->value()), static_cast(rotIncrement_->value()), axis, gonioHelicalStep_); gonio.ScreeningWedge(gonioScreeningWedge_); return gonio; } std::optional JFJochViewerSettingsDock::GridScanFromFields() const { if (!modeGrid_->isChecked()) return std::nullopt; // GridScanSettings throws on a zero step, and an exception thrown out of a widget signal aborts // the viewer - fall back to the default step for a half-typed entry. float step_x = static_cast(gridStepX_->value()); float step_y = static_cast(gridStepY_->value()); if (step_x == 0.0f) step_x = DEFAULT_GRID_STEP_UM; if (step_y == 0.0f) step_y = DEFAULT_GRID_STEP_UM; GridScanSettings grid(std::llround(gridNFast_->value()), step_x, step_y, gridSnake_->isChecked(), gridVertical_->isChecked()); // The rows are however many the images fill, exactly as the reader does when a file carries a // grid: the file stores n_fast alone. grid.ImageNum(experiment_.GetImageNum()); return grid; } QWidget *JFJochViewerSettingsDock::BuildReferenceSection() { // A reference dataset for scaling: drives CCref and reference-based scaling. It is // independent of the loaded data (the worker keeps it across file switches); this just lets the // user pick the MTZ + column and shows what it contains and whether it matches the data. auto *section = new CollapsibleSection("Reference dataset", this); auto *form = new QFormLayout(); form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); refButton_ = new QPushButton("Choose MTZ…", this); refColumn_ = new QComboBox(this); refColumn_->setEnabled(false); refColumn_->setToolTip("Reference intensity / structure-factor column (F is squared to an intensity)."); refSummary_ = new QLabel("No reference loaded", this); refSummary_->setWordWrap(true); refWarning_ = new QLabel(this); refWarning_->setWordWrap(true); refWarning_->setVisible(false); form->addRow(refButton_); form->addRow("Column", refColumn_); form->addRow(refSummary_); form->addRow(refWarning_); section->setContentLayout(form); connect(refButton_, &QPushButton::clicked, this, [this] { const QString path = QFileDialog::getOpenFileName(this, "Reference MTZ", refPath_, "MTZ files (*.mtz);;All files (*)"); if (path.isEmpty()) return; refPath_ = path; emit referenceSelected(path, QString()); // empty column -> let the worker auto-select }); // activated (not currentIndexChanged) so re-populating the combo on load doesn't re-trigger. connect(refColumn_, &QComboBox::activated, this, [this] { if (!refPath_.isEmpty()) emit referenceSelected(refPath_, refColumn_->currentText()); }); return section; } QWidget *JFJochViewerSettingsDock::BuildAzIntSection() { auto *azSection = new CollapsibleSection("Azimuthal integration", this); auto *az = new QFormLayout(); az->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto *lowQ = new SliderPlusBox(1e-5, 10.0, 0.001, 4, this); lowQ->setValue(azint_.GetLowQ_recipA()); auto *highQ = new SliderPlusBox(2e-5, 10.0, 0.001, 4, this); highQ->setValue(azint_.GetHighQ_recipA()); auto *autoHighQ = new QCheckBox("To detector edge", this); autoHighQ->setChecked(!azint_.GetRequestedHighQ_recipA().has_value()); autoHighQ->setToolTip("Integrate out to the highest Q the detector reaches. The high-Q value is not " "used while this is on."); auto *spacing = new SliderPlusBox(1e-5, 1.0, 0.001, 5, this, SliderPlusBox::ScaleType::Logarithmic); spacing->setValue(azint_.GetQSpacing_recipA()); auto *azimBins = new QComboBox(this); for (int b : {1, 2, 4, 8, 16, 32, 64, 128}) azimBins->addItem(QString::number(b), b); azimBins->setCurrentIndex(azimBins->findData(azint_.GetAzimuthalBinCount())); az->addRow("Low Q [Å⁻¹]", lowQ); az->addRow("", autoHighQ); az->addRow("High Q [Å⁻¹]", highQ); az->addRow("Q spacing [Å⁻¹]", spacing); az->addRow("Azimuthal bins", azimBins); azSection->setContentLayout(az); auto emitAz = [=, this] { azint_.QRange_recipA(static_cast(lowQ->value()), autoHighQ->isChecked() ? std::nullopt : std::optional(highQ->value())); azint_.QSpacing_recipA(static_cast(spacing->value())); azint_.AzimuthalBinCount(azimBins->currentData().toInt()); emit azintChanged(azint_); }; connect(lowQ, &SliderPlusBox::valueChanged, this, [emitAz] { emitAz(); }); connect(highQ, &SliderPlusBox::valueChanged, this, [emitAz] { emitAz(); }); connect(spacing, &SliderPlusBox::valueChanged, this, [emitAz] { emitAz(); }); connect(azimBins, &QComboBox::currentIndexChanged, this, [emitAz] { emitAz(); }); connect(autoHighQ, &QCheckBox::toggled, this, [emitAz, highQ](bool on) { highQ->setEnabled(!on); emitAz(); }); highQ->setEnabled(!autoHighQ->isChecked()); return azSection; } QWidget *JFJochViewerSettingsDock::BuildCalibrationPage() { auto *page = new QWidget(this); auto *layout = new QVBoxLayout(page); layout->setContentsMargins(0, 0, 0, 0); // The powder-calibration widget is the whole page: it already carries the calibrant and the two // interactive (current-image) fits, and it now also carries the method a whole-dataset run uses, // so the interactive and dataset paths share one calibrant selection. auto *powderSection = new CollapsibleSection("Powder calibration", page); auto *powderLayout = new QVBoxLayout(); powderLayout->setContentsMargins(0, 0, 0, 0); powder_ = new PowderCalibrationWidget(page); connect(powder_, &PowderCalibrationWidget::findBeamCenter, this, &JFJochViewerSettingsDock::findBeamCenter); connect(powder_, &PowderCalibrationWidget::ringsFromCalibration, this, &JFJochViewerSettingsDock::ringsFromCalibration); powderLayout->addWidget(powder_); // The rings method reads the ring's position at every azimuth, so the run has to be integrated in // azimuthal sectors; one sector is a plain radial profile and cannot locate the ring. The sector // count is the azimuthal-integration section above - say here when it is set too low to fit with. auto *bins = new QLabel(page); bins->setWordWrap(true); bins->setStyleSheet("color: gray;"); powderLayout->addWidget(bins); powderSection->setContentLayout(powderLayout); layout->addWidget(powderSection); auto refreshBins = [this, bins] { bins->setText(azint_.GetAzimuthalBinCount() < 4 ? QStringLiteral("Rings method: too few azimuthal bins (%1) to locate a ring;" " the run will use 32.").arg(azint_.GetAzimuthalBinCount()) : QString()); }; refreshBins(); connect(this, &JFJochViewerSettingsDock::azintChanged, this, [refreshBins] { refreshBins(); }); layout->addStretch(); return page; } void JFJochViewerSettingsDock::SyncMinPix() { // Per-image min-pix indexes each frame on its own, which only means something for stills; rotation // indexing builds one lattice from all frames, so it keeps the fixed value. spot_.min_pix_per_spot = (adaptive_min_pix_ && !indexing_.GetRotationIndexing()) ? std::optional() : std::optional(min_pix_value_); } void JFJochViewerSettingsDock::EmitSpotFinding() { SyncMinPix(); emit spotFindingChanged(spot_, indexing_, max_spots_); } void JFJochViewerSettingsDock::UpdateAlgorithmDescription() { if (!algo_ || !algoDesc_) return; const auto a = static_cast(algo_->currentData().toInt()); const bool gpu = get_gpu_count() > 0; const bool cell_known = cellKnown_ && cellKnown_->isChecked(); QString text; switch (a) { case IndexingAlgorithmEnum::FFBIDX: text = "GPU, needs a known cell — best for sparse serial stills"; break; case IndexingAlgorithmEnum::FFT: text = "GPU, de-novo — best for strong rotation data"; break; case IndexingAlgorithmEnum::FFTW: text = "CPU, de-novo — no GPU needed"; break; case IndexingAlgorithmEnum::Auto: { const IndexingAlgorithmEnum r = !gpu ? IndexingAlgorithmEnum::FFTW : (cell_known ? IndexingAlgorithmEnum::FFBIDX : IndexingAlgorithmEnum::FFT); const QString rn = r == IndexingAlgorithmEnum::FFBIDX ? "FFBIDX" : r == IndexingAlgorithmEnum::FFT ? "FFT" : "FFTW"; text = QString("resolves to %1 here (%2, cell %3)") .arg(rn, gpu ? "GPU" : "no GPU", cell_known ? "known" : "unknown"); break; } default: text = "no indexing"; break; } algoDesc_->setText(text); } QWidget *JFJochViewerSettingsDock::BuildBraggSection() { auto *section = new CollapsibleSection("Bragg integration", this); auto *form = new QFormLayout(); form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto *gaussian = new QCheckBox("Gaussian profile fit", this); gaussian->setChecked(bragg_.GetIntegrator() != IntegratorMode::BoxSum); gaussian->setToolTip("Profile-fit the spots (more accurate intensities); off = classical box-sum."); auto *r1 = new NumberLineEdit(1.0f, 30.0f, bragg_.GetR1(), 1, "px", this); auto *r2 = new NumberLineEdit(1.0f, 30.0f, bragg_.GetR2(), 1, "px", this); auto *r3 = new NumberLineEdit(1.0f, 30.0f, bragg_.GetR3(), 1, "px", this); auto *radii = new QHBoxLayout(); radii->addWidget(r1); radii->addWidget(r2); radii->addWidget(r3); // Background trim: use the old symmetric trimmed mean for the r2..r3 ring (drop the lowest and // highest fraction of ring pixels) in place of the default high-side sigma clip. Setting the trim // clears the clip, so unchecked simply leaves the default estimator in place. const float trim = bragg_.GetBackgroundTrimFraction(); auto *bkgTrim = new QCheckBox("Background trim", this); bkgTrim->setChecked(trim > 0.0f); bkgTrim->setToolTip("Estimate the local Bragg background with a symmetric trimmed mean of the " "background ring instead of the default high-side sigma clip. A symmetric trim " "is biased low on Poisson data and adds a few counts to every partial, so this " "is for back compatibility; unchecked = the 4 sigma clip."); auto *bkgTrimFrac = new NumberLineEdit(0.01f, 0.49f, trim > 0.0f ? trim : 0.10f, 2, "", this); bkgTrimFrac->setEnabled(bkgTrim->isChecked()); auto *trimRow = new QHBoxLayout(); trimRow->addWidget(bkgTrim); trimRow->addWidget(bkgTrimFrac, 1); form->addRow("", gaussian); form->addRow("Radii r1/r2/r3", radii); form->addRow("", trimRow); section->setContentLayout(form); auto emitBragg = [=, this] { bragg_.Integrator(gaussian->isChecked() ? IntegratorMode::ProfileGaussian : IntegratorMode::BoxSum); bragg_.R1(static_cast(r1->value())).R2(static_cast(r2->value())) .R3(static_cast(r3->value())); // Either estimator clears the other, so unchecking has to put the default clip back explicitly. if (bkgTrim->isChecked()) bragg_.BackgroundTrimFraction(static_cast(bkgTrimFrac->value())); else bragg_.BackgroundClipNSigma(BraggIntegrationSettings().GetBackgroundClipNSigma()); emit braggChanged(bragg_); }; connect(gaussian, &QCheckBox::toggled, this, [emitBragg] { emitBragg(); }); connect(bkgTrim, &QCheckBox::toggled, this, [bkgTrim, bkgTrimFrac, emitBragg] { bkgTrimFrac->setEnabled(bkgTrim->isChecked()); emitBragg(); }); connect(bkgTrimFrac, &NumberLineEdit::newValue, this, [emitBragg] { emitBragg(); }); for (auto *f : {r1, r2, r3}) connect(f, &NumberLineEdit::newValue, this, [emitBragg] { emitBragg(); }); return section; } QWidget *JFJochViewerSettingsDock::BuildScalingSection() { // The partiality model + rot3d combine + scale-fulls are driven by the rotation axis (goniometer // section); the panel keeps the full scaling_ so those fields are preserved here, not reset. auto *section = new CollapsibleSection("Scaling", this); auto *form = new QFormLayout(); form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); auto *friedel = new QCheckBox("Merge Friedel pairs", this); friedel->setChecked(scaling_.GetMergeFriedel()); auto *corrections = new QCheckBox("Correction surfaces (decay + absorption)", this); corrections->setChecked(scaling_.GetCorrectionSurfaces()); corrections->setToolTip("Rotation only: fit a radiation-damage decay and a goniometer-frame absorption " "surface on the fulls. Cross-validated, so they no-op when their systematic is " "absent. On by default."); auto *partRefine = new QCheckBox("Partiality post-refinement (stills)", this); partRefine->setChecked(scaling_.GetStillsPartialityRefine()); partRefine->setToolTip("Stills: refine a per-crystal orientation tilt against the running merge and " "recompute each reflection's partiality (physical Ewald-proximity model), then " "re-scale/merge. On by default; uncheck for the simple model (each reflection a " "full, single-pass). No effect on rotation data."); auto *limitRes = new QCheckBox("High-resolution limit", this); limitRes->setChecked(scaling_.GetHighResolutionLimit_A().has_value()); auto *highRes = new NumberLineEdit(0.3f, 5.0f, scaling_.GetHighResolutionLimit_A().value_or(2.0), 1, "Å", this); highRes->setEnabled(limitRes->isChecked()); auto *limitLowRes = new QCheckBox("Low-resolution limit", this); limitLowRes->setChecked(scaling_.GetLowResolutionLimit_A().has_value()); limitLowRes->setToolTip("Drop reflections coarser than this from scaling and merging. They sit behind " "or beside the beam stop and are measured on a background it has eaten into. " "On by default at 50 Å, the value XDS configurations use."); auto *lowRes = new NumberLineEdit(5.0f, 500.0f, scaling_.GetLowResolutionLimit_A().value_or(50.0), 1, "Å", this); lowRes->setEnabled(limitLowRes->isChecked()); form->addRow("", friedel); form->addRow("", corrections); form->addRow("", partRefine); // Compact, and aligned with the checkboxes above: the limit checkbox + value sit together in the // field column (not as a row label, which would indent it differently). auto *resRow = new QHBoxLayout(); resRow->addWidget(limitRes); resRow->addWidget(highRes, 1); form->addRow("", resRow); auto *lowResRow = new QHBoxLayout(); lowResRow->addWidget(limitLowRes); lowResRow->addWidget(lowRes, 1); form->addRow("", lowResRow); section->setContentLayout(form); auto emitScaling = [=, this] { scaling_.MergeFriedel(friedel->isChecked()); scaling_.CorrectionSurfaces(corrections->isChecked()); scaling_.StillsPartialityRefine(partRefine->isChecked()); scaling_.HighResolutionLimit_A(limitRes->isChecked() ? std::optional(highRes->value()) : std::nullopt); scaling_.LowResolutionLimit_A(limitLowRes->isChecked() ? std::optional(lowRes->value()) : std::nullopt); emit scalingChanged(scaling_); }; connect(friedel, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(corrections, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(partRefine, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); }); connect(limitRes, &QCheckBox::toggled, this, [emitScaling, highRes](bool on) { highRes->setEnabled(on); emitScaling(); }); connect(highRes, &NumberLineEdit::newValue, this, [emitScaling] { emitScaling(); }); connect(limitLowRes, &QCheckBox::toggled, this, [emitScaling, lowRes](bool on) { lowRes->setEnabled(on); emitScaling(); }); connect(lowRes, &NumberLineEdit::newValue, this, [emitScaling] { emitScaling(); }); return section; } void JFJochViewerSettingsDock::EmitExperiment() { if (!have_experiment_) return; experiment_.IncidentEnergy_keV(static_cast(energy_->value())); experiment_.DetectorDistance_mm(static_cast(distance_->value())); experiment_.BeamX_pxl(static_cast(beamX_->value())); experiment_.BeamY_pxl(static_cast(beamY_->value())); experiment_.PoniRot1_rad(static_cast(rot1_->value() * PI / 180.0)); experiment_.PoniRot2_rad(static_cast(rot2_->value() * PI / 180.0)); experiment_.PolarizationFactor(polarizationOn_->isChecked() ? std::optional(static_cast(polarization_->value())) : std::nullopt); experiment_.Goniometer(GoniometerFromFields()); experiment_.GridScan(GridScanFromFields()); if (cellKnown_->isChecked()) { experiment_.SetUnitCell(UnitCell{ static_cast(cellA_->value()), static_cast(cellB_->value()), static_cast(cellC_->value()), static_cast(cellAlpha_->value()), static_cast(cellBeta_->value()), static_cast(cellGamma_->value())}); const int sg = static_cast(std::lround(spaceGroup_->value())); experiment_.SpaceGroupNumber(sg > 0 ? std::optional(sg) : std::nullopt); } else { experiment_.SetUnitCell(std::nullopt); experiment_.SpaceGroupNumber(std::nullopt); } emit experimentChanged(experiment_); } void JFJochViewerSettingsDock::RefreshGeometryFields() { // Populate fields from the loaded experiment. NumberLineEdit::setValue does not emit newValue // (that fires only on user editing), so this cannot feed back into EmitExperiment. energy_->setValue(experiment_.GetIncidentEnergy_keV()); distance_->setValue(experiment_.GetDetectorDistance_mm()); beamX_->setValue(experiment_.GetBeamX_pxl()); beamY_->setValue(experiment_.GetBeamY_pxl()); rot1_->setValue(experiment_.GetPoniRot1_rad() * 180.0 / PI); rot2_->setValue(experiment_.GetPoniRot2_rad() * 180.0 / PI); const auto polarization = experiment_.GetPolarizationFactor(); QSignalBlocker blockPolarization(polarizationOn_); polarizationOn_->setChecked(polarization.has_value()); polarization_->setEnabled(polarization.has_value()); if (polarization) polarization_->setValue(polarization.value()); // The mode follows what the experiment describes, and the described one's fields are filled from // it. The other two keep what they show - the generic defaults, or what the user last entered - // so switching away and back does not lose an axis or a grid. const auto gonio = experiment_.GetGoniometer(); const auto grid = experiment_.GetGridScan(); QSignalBlocker blockStill(modeStill_), blockRotation(modeRotation_), blockGrid(modeGrid_); modeRotation_->setChecked(gonio.has_value()); modeGrid_->setChecked(!gonio.has_value() && grid.has_value()); modeStill_->setChecked(!gonio.has_value() && !grid.has_value()); EnableRotationFields(gonio.has_value()); EnableGridFields(modeGrid_->isChecked()); if (gonio) { axisName_->setText(QString::fromStdString(gonio->GetName())); axisX_->setValue(gonio->GetAxis().x); axisY_->setValue(gonio->GetAxis().y); axisZ_->setValue(gonio->GetAxis().z); rotStart_->setValue(gonio->GetStart_deg()); rotIncrement_->setValue(gonio->GetIncrement_deg()); gonioHelicalStep_ = gonio->GetHelicalStep(); gonioScreeningWedge_ = gonio->GetScreeningWedge(); } if (grid) { gridNFast_->setValue(grid->GetNFast()); gridStepX_->setValue(grid->GetGridStepX_um()); gridStepY_->setValue(grid->GetGridStepY_um()); QSignalBlocker blockVertical(gridVertical_), blockSnake(gridSnake_); gridVertical_->setChecked(grid->IsVerticalScan()); gridSnake_->setChecked(grid->IsSnakeScan()); } UpdateGridSummary(); const auto cell = experiment_.GetUnitCell(); QSignalBlocker blockKnown(cellKnown_); cellKnown_->setChecked(cell.has_value()); for (auto *f : {cellA_, cellB_, cellC_, cellAlpha_, cellBeta_, cellGamma_, spaceGroup_}) f->setEnabled(cell.has_value()); if (cell) { cellA_->setValue(cell->a); cellB_->setValue(cell->b); cellC_->setValue(cell->c); cellAlpha_->setValue(cell->alpha); cellBeta_->setValue(cell->beta); cellGamma_->setValue(cell->gamma); spaceGroup_->setValue(experiment_.GetSpaceGroupNumber().value_or(0)); } UpdateSpaceGroupName(); } void JFJochViewerSettingsDock::UpdateSpaceGroupName() { if (!cellKnown_->isChecked()) { spaceGroupName_->setText("—"); return; } const int n = static_cast(std::lround(spaceGroup_->value())); if (n >= 1 && n <= 230) { try { const auto &sg = gemmi::get_spacegroup_by_number(n); // Hermann–Mauguin short symbol spaceGroupName_->setText(QString::fromStdString(sg.short_name())); } catch (...) { spaceGroupName_->setText("invalid"); } } else { spaceGroupName_->setText(n == 0 ? "—" : "invalid"); } } void JFJochViewerSettingsDock::datasetLoaded(std::shared_ptr dataset) { if (!dataset) return; experiment_ = dataset->experiment; have_experiment_ = true; RefreshGeometryFields(); ApplyProcessingMode(); // the mode just read back decides rotation vs stills } void JFJochViewerSettingsDock::ApplyProcessingMode() { // Rotation is the only mode that changes how the data is processed: the rotation good-path // (rotation indexing + Ewald partiality + rot3d combine + scale-fulls). A grid scan is a raster // of stills, so it runs exactly as Still does. const bool rotation_mode = modeRotation_ && modeRotation_->isChecked(); indexing_.RotationIndexing(rotation_mode); // rotation indexing -> rotation scaling/merge downstream scaling_.ScaleFulls(rotation_mode); EmitSpotFinding(); // carries indexing_ (incl. RotationIndexing) to the worker emit scalingChanged(scaling_); } void JFJochViewerSettingsDock::loadImage(std::shared_ptr image) { if (powder_) powder_->loadImage(image); } void JFJochViewerSettingsDock::referenceLoaded(ReferenceMtzInfo info) { { QSignalBlocker block(refColumn_); // re-populating must not emit referenceSelected refColumn_->clear(); refColumn_->addItems(info.columns); const int idx = refColumn_->findText(info.used_column); if (idx >= 0) refColumn_->setCurrentIndex(idx); } refColumn_->setEnabled(info.loaded && !info.columns.isEmpty()); refSummary_->setText(info.loaded ? info.summary : "No reference loaded"); if (info.warning.isEmpty()) { refWarning_->setVisible(false); } else { // Amber = a loaded-but-mismatched reference (a caution); red = a load failure. refWarning_->setStyleSheet(info.loaded ? "color: #B8860B;" : "color: #C0392B;"); refWarning_->setText(info.warning); refWarning_->setVisible(true); } }