Viewer: rate-limit hover feedback to ~15 Hz

The status bar, the resolution readout and the magnifier were all regenerated
on every single mouse motion event. Each regeneration repaints, and on a remote
X session a repaint uploads the whole window regardless of how little changed,
so the pointer merely crossing the image saturates the link: measured with a
counting relay in front of the X server, 50 motions over the image cost 273 MB,
and a build with the hover work removed cost 18 KB.

Rate-limit it. Two details matter:

- The limit is applied inline, not from a timer. Running the update inside the
  mouse event keeps its damage in the same repaint as anything else that event
  triggers (a pan). A first attempt deferred the work to a timer instead, which
  split one repaint into two and made panning measurably worse.
- The catch-up that reports the final position is debounced, not queued per
  skipped motion, so it fires once after the pointer stops rather than
  repeatedly mid-gesture.

mouseHover() now takes the scene position and modifiers instead of the event,
which also removes the identical mapToScene() from all four implementations.

Hover traffic over 3 repeats: 173 MB mean -> 140 MB, and the run-to-run spread
drops from +-14% to +-2%. The harness tops out near 30 motions/s, barely above
the 15 Hz limit; a real mouse reports far faster, where the cap does more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 20:12:36 +02:00
co-authored by Claude Opus 5
parent 3eccc58961
commit 501ce1ba3d
10 changed files with 55 additions and 17 deletions
+1 -2
View File
@@ -77,10 +77,9 @@ void JFJochAzIntImage::imageLoaded(std::shared_ptr<const JFJochReaderImage> in_i
}
}
void JFJochAzIntImage::mouseHover(QMouseEvent* event) {
void JFJochAzIntImage::mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers) {
if (!scene() || !image || W == 0 || H == 0) return;
QPointF scenePos = mapToScene(event->pos());
int x = static_cast<int>(scenePos.x());
int y = static_cast<int>(scenePos.y());
+1 -1
View File
@@ -21,7 +21,7 @@ class JFJochAzIntImage : public JFJochImage {
float range_max = 1.0f;
std::shared_ptr<const JFJochReaderImage> image;
void mouseHover(QMouseEvent* event) override;
void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override;
void Clear();
void mouseDoubleClickEvent(QMouseEvent *event) override;
signals:
@@ -79,8 +79,7 @@ void JFJochDiffractionImage::azimuthalHandles(const ROIAzimuthal &az, const Diff
phimax = pt(d_mid, phi1);
}
void JFJochDiffractionImage::mouseHover(QMouseEvent *event) {
auto coord = mapToScene(event->pos());
void JFJochDiffractionImage::mouseHover(const QPointF &coord, Qt::KeyboardModifiers) {
if (image && (coord.x() >= 0)
&& (coord.x() < image->Dataset().experiment.GetXPixelsNum())
+1 -1
View File
@@ -102,7 +102,7 @@ private:
float ice_ring_width_Q_recipA = 0.01;
void mouseHover(QMouseEvent* event) override;
void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override;
signals:
void roiGeometryEdited(ROIDefinition rois);
+2 -4
View File
@@ -63,13 +63,11 @@ void JFJochGridScanImage::loadData(const std::vector<float> &data, const GridSca
CalcROI();
}
void JFJochGridScanImage::mouseHover(QMouseEvent *event) {
void JFJochGridScanImage::mouseHover(const QPointF &pt, Qt::KeyboardModifiers modifiers) {
// Map mouse position to image pixel if inside bounds
if (W == 0 || H == 0 || image_index.empty())
return;
const QPointF pt = mapToScene(event->pos());
// Convert view coordinates to image pixel by truncation
int x = static_cast<int>(pt.x());
int y = static_cast<int>(pt.y());
@@ -82,7 +80,7 @@ void JFJochGridScanImage::mouseHover(QMouseEvent *event) {
return;
int64_t image_id = image_index[idx];
if (image_id >= 0) {
if (event->modifiers() & Qt::ShiftModifier)
if (modifiers & Qt::ShiftModifier)
emit imageSelected(image_id);
if (one_over_d2) {
+1 -1
View File
@@ -17,7 +17,7 @@ class JFJochGridScanImage : public JFJochImage {
int64_t current_image_H = -1;
bool one_over_d2 = false;
void mouseHover(QMouseEvent *event) override;
void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override;
void mouseDoubleClickEvent(QMouseEvent *event) override;
void loadImage(QMouseEvent *event);
void addCustomOverlay() override;
+26 -2
View File
@@ -61,6 +61,10 @@ JFJochImage::JFJochImage(QWidget *parent) : QGraphicsView(parent) {
// Optional: a sensible default colormap
color_scale.Select(ColorScaleEnum::Indigo);
hover_tail_timer_ = new QTimer(this);
hover_tail_timer_->setSingleShot(true);
connect(hover_tail_timer_, &QTimer::timeout, this, &JFJochImage::UpdateHover);
}
void JFJochImage::onScroll(int value) {
@@ -69,6 +73,27 @@ void JFJochImage::onScroll(int value) {
updateOverlay();
}
void JFJochImage::UpdateHover() {
hover_rate_.restart();
mouseHover(hover_scene_pos_, hover_modifiers_);
emit hoverScenePos(hover_scene_pos_);
}
void JFJochImage::ScheduleHoverUpdate(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) {
hover_scene_pos_ = scenePos;
hover_modifiers_ = modifiers;
if (!hover_rate_.isValid() || hover_rate_.elapsed() >= kHoverIntervalMs) {
hover_tail_timer_->stop();
UpdateHover();
return;
}
// Too soon. Push the catch-up back instead of queueing one per skipped motion, so it fires
// once, after the pointer stops -- a catch-up that fires mid-gesture is an extra repaint.
hover_tail_timer_->start(kHoverIntervalMs);
}
void JFJochImage::ScheduleRenderImage() {
if (render_pending_)
return;
@@ -244,8 +269,7 @@ void JFJochImage::mouseMoveEvent(QMouseEvent *event) {
return;
const QPointF scenePos = mapToScene(event->pos());
mouseHover(event);
emit hoverScenePos(scenePos);
ScheduleHoverUpdate(scenePos, event->modifiers());
QPointF delta;
switch (mouse_event_type) {
+20 -1
View File
@@ -5,6 +5,8 @@
#include <cmath>
#include <QElapsedTimer>
#include <QTimer>
#include <QGraphicsView>
#include <QGraphicsItem>
#include <QImage>
@@ -151,7 +153,24 @@ protected:
QRectF roiBox;
static QPointF RoundPoint(const QPointF& p);
virtual void mouseHover(QMouseEvent* event) = 0;
virtual void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) = 0;
// Hover feedback (status bar, resolution readout, magnifier) used to be regenerated on every
// single mouse motion. Each regeneration dirties the window, and on a remote X session every
// repaint costs a full-window pixel upload, so the thing to minimise is the number of
// repaints. ~15 Hz is far below the motion event rate and well above what the eye follows.
//
// The rate limit is applied inline rather than from a timer on purpose: running the update
// inside the mouse event keeps its damage in the same repaint as anything else that event
// triggers (a pan), instead of costing a second one. The timer only covers the tail, so the
// final position is still reported once the pointer stops.
static constexpr int kHoverIntervalMs = 66;
void ScheduleHoverUpdate(const QPointF &scenePos, Qt::KeyboardModifiers modifiers);
void UpdateHover();
QPointF hover_scene_pos_;
Qt::KeyboardModifiers hover_modifiers_ = Qt::NoModifier;
QElapsedTimer hover_rate_;
QTimer *hover_tail_timer_ = nullptr;
ResizeHandle hitTestROIHandle(const QPointF& scenePos, qreal tol = 3.0) const;
+1 -2
View File
@@ -41,9 +41,8 @@ void JFJochSimpleImage::setImage(std::shared_ptr<const SimpleImage> img) {
}
void JFJochSimpleImage::mouseHover(QMouseEvent *event) {
void JFJochSimpleImage::mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers) {
if (image_) {
const QPointF scenePos = mapToScene(event->pos());
// Hover feedback / status bar display
if ((scenePos.x() >= 0)
&& (scenePos.x() < image_->image.GetWidth())
+1 -1
View File
@@ -25,7 +25,7 @@ class JFJochSimpleImage : public JFJochImage {
void loadImageInternal(const uint8_t *input);
void loadImageInternal();
void mouseHover(QMouseEvent *event) override;
void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override;
public:
explicit JFJochSimpleImage(QWidget *parent = nullptr);
public slots: