Files
Jungfraujoch/viewer/widgets/JFJochViewerROIList.cpp
T
leonarski_fandClaude Opus 5.5 9cf987cd4e jfjoch_viewer: live updates no longer rebuild open combo boxes
During a collection every dataset tick ran JFJochViewerDatasetInfo::UpdateLabels(), which
clear()ed the plot-type combo and re-added every item, so an open dropdown lost its hovered
row and scroll position on each tick and picking a plot type was a race against the next
update. The same clear-and-refill ran per tick or per image in the calibration window's
calibration combo, the ROI list combo and the powder calibrant combo; the last two also
threw away the user's choice (the calibrant was reset to the first entry on every image).

New LiveComboBox::SetItems() takes the full item list and leaves the combo untouched when the
list is unchanged. When it does change while the popup is open, the change is held back and
applied (queued) once the popup window hides, so a click that closes the popup lands first.
A replacement keeps the selection by item data, blocks signals, and a held-back one emits
itemsReplaced() so the owner can re-plot.

The calibration window now re-shows the selected calibration on a dataset tick instead of
always reloading the pixel mask over it.

Tested offscreen: a timer fires datasetLoaded() every 25 ms while the dropdown is open; the
popup and hovered row survive, newly available indexing metrics are held back until the popup
closes, and the picked type sticks and is what the chart plots. The rc175 code fails the
hover checks of the same harness.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:46:48 +02:00

288 lines
11 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochViewerROIList.h"
#include <set>
#include <cmath>
#include "../../common/JFJochMath.h"
#include "ROIColorPalette.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
#include <QPushButton>
#include <QInputDialog>
JFJochViewerROIList::JFJochViewerROIList(QWidget *parent) : QWidget(parent) {
auto *layout = new QVBoxLayout(this);
combo_ = new LiveComboBox(this);
layout->addWidget(combo_);
connect(combo_, &QComboBox::currentIndexChanged, this, &JFJochViewerROIList::OnSelectionChanged);
connect(combo_, &LiveComboBox::itemsReplaced, this, &JFJochViewerROIList::OnSelectionChanged);
auto *buttons = new QHBoxLayout();
type_combo_ = new QComboBox(this);
type_combo_->addItems({"Box", "Circle", "Azimuthal"});
buttons->addWidget(type_combo_);
auto *add = new QPushButton("+", this);
auto *ren = new QPushButton("Rename", this);
auto *del = new QPushButton("Delete", this);
buttons->addWidget(add);
buttons->addWidget(ren);
buttons->addWidget(del);
layout->addLayout(buttons);
connect(add, &QPushButton::clicked, this, &JFJochViewerROIList::OnAdd);
connect(ren, &QPushButton::clicked, this, &JFJochViewerROIList::OnRename);
connect(del, &QPushButton::clicked, this, &JFJochViewerROIList::OnDelete);
auto *server = new QHBoxLayout();
auto *download = new QPushButton("Download", this);
auto *upload = new QPushButton("Upload to server", this);
server->addWidget(download);
server->addWidget(upload);
layout->addLayout(server);
connect(download, &QPushButton::clicked, this, &JFJochViewerROIList::downloadROIs);
connect(upload, &QPushButton::clicked, this, &JFJochViewerROIList::uploadROIs);
auto *grid = new QGridLayout();
for (int i = 0; i < 4; i++) {
flabel_[i] = new QLabel("", this);
fedit_[i] = new QLineEdit(this);
fedit_[i]->setAlignment(Qt::AlignRight);
fedit_[i]->setMaximumWidth(90);
grid->addWidget(flabel_[i], i, 0);
grid->addWidget(fedit_[i], i, 1, Qt::AlignRight);
connect(fedit_[i], &QLineEdit::editingFinished, this, &JFJochViewerROIList::ApplyEditor);
}
layout->addLayout(grid);
auto *mask = new QHBoxLayout();
auto *mask_add = new QPushButton("Add to mask", this);
auto *mask_sub = new QPushButton("Subtract from mask", this);
mask->addWidget(mask_add);
mask->addWidget(mask_sub);
layout->addLayout(mask);
connect(mask_add, &QPushButton::clicked, [this] {
if (!SelectedName().isEmpty()) emit maskFromROI(SelectedName(), true);
});
connect(mask_sub, &QPushButton::clicked, [this] {
if (!SelectedName().isEmpty()) emit maskFromROI(SelectedName(), false);
});
result_ = new JFJochViewerROIResult(this);
layout->addWidget(result_);
}
void JFJochViewerROIList::UpdateEditor() {
const std::string sel = SelectedName().toStdString();
auto set = [&](int i, const QString &label, double val, bool integer) {
flabel_[i]->setText(label);
flabel_[i]->setVisible(!label.isEmpty());
fedit_[i]->setVisible(!label.isEmpty());
if (!label.isEmpty())
fedit_[i]->setText(integer ? QString::number(std::lround(val)) : QString::number(val, 'g', 6));
};
for (int i = 0; i < 4; i++)
set(i, "", 0, false);
for (const auto &b : rois_.boxes)
if (b.GetName() == sel) {
set(0, "Min X", b.GetXMin(), true); set(1, "Max X", b.GetXMax(), true);
set(2, "Min Y", b.GetYMin(), true); set(3, "Max Y", b.GetYMax(), true);
return;
}
for (const auto &c : rois_.circles)
if (c.GetName() == sel) {
set(0, "Center X", c.GetX(), false); set(1, "Center Y", c.GetY(), false);
set(2, "Radius", c.GetRadius_pxl(), false); set(3, "", 0, false);
return;
}
for (const auto &a : rois_.azimuthal)
if (a.GetName() == sel) {
set(0, "Q min [1/A]", a.GetQMin_recipA(), false); set(1, "Q max [1/A]", a.GetQMax_recipA(), false);
set(2, "phi min [deg]", a.GetPhiMin_deg(), false); set(3, "phi max [deg]", a.GetPhiMax_deg(), false);
return;
}
}
void JFJochViewerROIList::ApplyEditor() {
if (rebuilding_)
return;
const std::string sel = SelectedName().toStdString();
bool changed = false;
for (auto &b : rois_.boxes)
if (b.GetName() == sel) {
b = ROIBox(sel, fedit_[0]->text().toLongLong(), fedit_[1]->text().toLongLong(),
fedit_[2]->text().toLongLong(), fedit_[3]->text().toLongLong());
changed = true; break;
}
for (auto &c : rois_.circles)
if (c.GetName() == sel) {
c = ROICircle(sel, fedit_[0]->text().toDouble(), fedit_[1]->text().toDouble(),
std::max(0.1, fedit_[2]->text().toDouble()));
changed = true; break;
}
for (auto &a : rois_.azimuthal)
if (a.GetName() == sel) {
const float qmin = fedit_[0]->text().toFloat();
const float qmax = fedit_[1]->text().toFloat();
const float phimin = fedit_[2]->text().toFloat();
const float phimax = fedit_[3]->text().toFloat();
const float d_min = (qmax > 0) ? 2.0f * static_cast<float>(PI) / qmax : a.GetDMin_A();
const float d_max = (qmin > 0) ? 2.0f * static_cast<float>(PI) / qmin : a.GetDMax_A();
a = ROIAzimuthal(sel, d_min, d_max, phimin, phimax);
changed = true; break;
}
if (changed)
emit roisChanged(rois_);
}
void JFJochViewerROIList::loadImage(std::shared_ptr<const JFJochReaderImage> image) {
image_ = image;
const QString keep = SelectedName();
rois_ = image_ ? image_->Dataset().experiment.ROI().GetROIDefinition() : ROIDefinition{};
RebuildCombo(keep);
ShowSelectedResult();
UpdateEditor();
}
void JFJochViewerROIList::RebuildCombo(const QString &keep_selected) {
rebuilding_ = true;
// Running index across boxes/circles/azimuthal - matches the enumeration order used by the
// image overlay, so each item's swatch colour equals its annotation colour on the image.
// Called on every image of a live acquisition; the combo only changes when the list does.
QVector<LiveComboBox::Item> items;
auto add_item = [&](const std::string &name, const char *type) {
// display "name · type", with the bare name kept as item data for lookups
items.push_back({QString("%1 · %2").arg(QString::fromStdString(name), type),
QString::fromStdString(name), QString(),
ROIAnnotationColor(static_cast<int>(items.size()))});
};
for (const auto &b : rois_.boxes) add_item(b.GetName(), "box");
for (const auto &c : rois_.circles) add_item(c.GetName(), "circle");
for (const auto &a : rois_.azimuthal) add_item(a.GetName(), "azim");
combo_->SetItems(items);
if (!keep_selected.isEmpty()) {
const int idx = combo_->findData(keep_selected);
if (idx >= 0)
combo_->setCurrentIndex(idx);
}
rebuilding_ = false;
}
QString JFJochViewerROIList::SelectedName() const {
return combo_->currentData().toString();
}
void JFJochViewerROIList::setSelected(QString name) {
const int idx = combo_->findData(name);
if (idx >= 0 && idx != combo_->currentIndex())
combo_->setCurrentIndex(idx); // updates the stats and selection highlight
}
void JFJochViewerROIList::ShowSelectedResult() {
const QString name = SelectedName();
if (image_ && !name.isEmpty()) {
const auto &roi = image_->ImageData().roi;
auto it = roi.find(name.toStdString());
if (it != roi.end()) {
result_->SetROIResult(it->second);
return;
}
}
result_->SetROIResult(ROIMessage{}); // clears the display
}
std::string JFJochViewerROIList::UniqueName() const {
std::set<std::string> used;
for (const auto &b : rois_.boxes) used.insert(b.GetName());
for (const auto &c : rois_.circles) used.insert(c.GetName());
for (const auto &a : rois_.azimuthal) used.insert(a.GetName());
for (int i = 1; ; i++) {
std::string n = "roi" + std::to_string(i);
if (!used.count(n)) return n;
}
}
void JFJochViewerROIList::OnSelectionChanged() {
if (rebuilding_)
return;
ShowSelectedResult();
UpdateEditor();
emit selectedROIChanged(SelectedName());
}
void JFJochViewerROIList::OnAdd() {
if (rois_.boxes.size() + rois_.circles.size() + rois_.azimuthal.size() >= 16)
return; // bitmap allows at most 16 ROIs
const std::string name = UniqueName();
int64_t cx = 100, cy = 100;
if (image_) {
cx = image_->Dataset().experiment.GetXPixelsNum() / 2;
cy = image_->Dataset().experiment.GetYPixelsNum() / 2;
}
switch (type_combo_->currentIndex()) {
case 0: rois_.boxes.emplace_back(name, cx - 100, cx + 100, cy - 100, cy + 100); break;
case 1: rois_.circles.emplace_back(name, cx, cy, 100); break;
default: rois_.azimuthal.emplace_back(name, 2.0f, 4.0f); break;
}
emit roisChanged(rois_);
RebuildCombo(QString::fromStdString(name));
ShowSelectedResult();
}
void JFJochViewerROIList::OnDelete() {
const std::string name = SelectedName().toStdString();
if (name.empty())
return;
auto erase_by_name = [&name](auto &vec) {
for (auto it = vec.begin(); it != vec.end(); ++it)
if (it->GetName() == name) { vec.erase(it); return true; }
return false;
};
if (erase_by_name(rois_.boxes) || erase_by_name(rois_.circles) || erase_by_name(rois_.azimuthal)) {
emit roisChanged(rois_);
RebuildCombo(QString());
ShowSelectedResult();
}
}
void JFJochViewerROIList::OnRename() {
const QString old_name = SelectedName();
if (old_name.isEmpty())
return;
bool ok = false;
const QString new_name = QInputDialog::getText(this, "Rename ROI", "New name:",
QLineEdit::Normal, old_name, &ok);
if (!ok || new_name.isEmpty() || new_name == old_name)
return;
const std::string oldn = old_name.toStdString();
const std::string newn = new_name.toStdString();
for (auto &b : rois_.boxes)
if (b.GetName() == oldn) { b = ROIBox(newn, b.GetXMin(), b.GetXMax(), b.GetYMin(), b.GetYMax()); break; }
for (auto &c : rois_.circles)
if (c.GetName() == oldn) { c = ROICircle(newn, c.GetX(), c.GetY(), c.GetRadius_pxl()); break; }
for (auto &a : rois_.azimuthal)
if (a.GetName() == oldn) {
a = a.HasPhi() ? ROIAzimuthal(newn, a.GetDMin_A(), a.GetDMax_A(), a.GetPhiMin_deg(), a.GetPhiMax_deg())
: ROIAzimuthal(newn, a.GetDMin_A(), a.GetDMax_A());
break;
}
emit roisChanged(rois_);
RebuildCombo(new_name);
ShowSelectedResult();
}