wiRedPanda
Logic Circuit Simulator
Loading...
Searching...
No Matches
MinimapWidget.cpp
Go to the documentation of this file.
1// Copyright 2015 - 2026, GIBIS-UNIFESP and the wiRedPanda contributors
2// SPDX-License-Identifier: GPL-3.0-or-later
3
5
6#include <QCursor>
7#include <QEvent>
8#include <QMouseEvent>
9#include <QPaintDevice>
10#include <QPainter>
11#include <QScrollBar>
12#include <QTimer>
13
16#include "App/Scene/Scene.h"
17
18MinimapWidget::MinimapWidget(Scene *scene, GraphicsView *view, QWidget *parent)
19 : QWidget(parent)
20 , m_scene(scene)
21 , m_view(view)
22{
23 setAttribute(Qt::WA_OpaquePaintEvent);
24 setAttribute(Qt::WA_NoSystemBackground, false);
25 setFocusPolicy(Qt::NoFocus);
26 // Resizable via drag handles on the edges/corners (see resizeModeAt()); the minimum
27 // keeps the viewport-outline overlay and mascot content legible.
28 setMinimumSize(160, 120);
29 resize(220, 160);
30 setMouseTracking(true); // hover cursor feedback over resize/move handles without a button held
31 setToolTip(tr("Mini-map: click or drag to navigate"));
32 setAccessibleName(tr("Circuit minimap"));
33 setWhatsThis(tr("A miniature overview of the whole circuit. Click or drag inside it to "
34 "jump the main canvas to that location."));
35
36 if (m_scene) {
38 }
39
40 // The thumbnail is a cached render of the scene (see paintEvent()/m_cache), so a theme
41 // switch -- which recolors the scene's background/dots/elements in place, the same way
42 // Scene::updateTheme() does for the main view -- leaves the cached pixmap showing the old
43 // theme's colors until the next real circuit edit. Invalidate it here too.
45
46 if (m_view) {
48 if (m_view->horizontalScrollBar())
49 connect(m_view->horizontalScrollBar(), &QScrollBar::valueChanged, this, &MinimapWidget::updateOverlay);
50 if (m_view->verticalScrollBar())
51 connect(m_view->verticalScrollBar(), &QScrollBar::valueChanged, this, &MinimapWidget::updateOverlay);
52 if (m_view->viewport())
53 m_view->viewport()->installEventFilter(this);
54 }
55
56 m_throttle.setInterval(200);
57 m_throttle.setSingleShot(true);
58 connect(&m_throttle, &QTimer::timeout, this, &MinimapWidget::onThrottleTimeout);
59}
60
62{
63 return QSize(200, 150);
64}
65
66void MinimapWidget::paintEvent(QPaintEvent * /*event*/)
67{
68 if (!m_scene || !m_view) {
69 return;
70 }
71 QPainter painter(this);
72 painter.fillRect(rect(), palette().window().color());
73 painter.setRenderHint(QPainter::Antialiasing, false);
74
75 QRectF srcUsed;
76 double scale = 1.0, dx = 0.0, dy = 0.0, usedW = 0.0, usedH = 0.0;
77 if (!computeTransform(srcUsed, scale, dx, dy, usedW, usedH))
78 return;
79
80 const qreal dpr = this->devicePixelRatioF();
81 const QSize pixSize = QSize(qRound(usedW * dpr), qRound(usedH * dpr));
82 // Regenerate when the source region changes too: unlike the old item-box source, the source
83 // now tracks sceneRect / the visible viewport, which change on zoom -- a stale cache would
84 // otherwise show the wrong region.
85 if (m_cacheDirty || m_cache.size() != pixSize || m_cacheSrc != srcUsed) {
86 m_cache = QPixmap(pixSize);
87 m_cache.setDevicePixelRatio(dpr);
88 m_cache.fill(palette().window().color());
89 QPainter pixmapPainter(&m_cache);
90 pixmapPainter.setRenderHint(QPainter::Antialiasing, false);
91 m_scene->render(&pixmapPainter, QRectF(0, 0, usedW, usedH), srcUsed, Qt::KeepAspectRatio);
92 m_cacheDirty = false;
93 m_cacheSrc = srcUsed;
94 }
95
96 // Draw the cached pixmap at the computed offset so the minimap content
97 // aligns with the widget's mapping and avoids letterbox misalignment.
98 painter.drawPixmap(QRectF(dx, dy, usedW, usedH), m_cache, QRectF(0, 0, usedW, usedH));
99
100 // Draw viewport rectangle in minimap coordinates
101 const QRectF visible = m_view->mapToScene(m_view->viewport()->rect()).boundingRect();
102
103 QRectF vr(
104 dx + (visible.left() - srcUsed.left()) * scale,
105 dy + (visible.top() - srcUsed.top()) * scale,
106 visible.width() * scale,
107 visible.height() * scale);
108
109 QPen pen(palette().highlight().color());
110 pen.setWidthF(1.6);
111 painter.setPen(pen);
112 painter.setBrush(Qt::NoBrush);
113 painter.drawRect(vr);
114
115 painter.setRenderHint(QPainter::Antialiasing, true);
116 drawMoveHandle(painter);
117 drawResizeGrips(painter);
118}
119
120void MinimapWidget::mousePressEvent(QMouseEvent *event)
121{
122 if (!m_scene || !m_view) {
123 return;
124 }
125
126 if (event->button() == Qt::LeftButton) {
127 const ResizeMode mode = resizeModeAt(event->pos());
128 if (mode != ResizeMode::None) {
129 m_resizing = true;
130 m_resizeMode = mode;
131 m_lastGlobalPos = event->globalPosition().toPoint();
132 grabMouse();
133 event->accept();
134 return;
135 }
136
137 if (isMoveHandle(event->pos())) {
138 m_moving = true;
139 m_lastGlobalPos = event->globalPosition().toPoint();
140 grabMouse();
141 event->accept();
142 return;
143 }
144 }
145
146 m_view->centerOn(widgetToScene(event->pos()));
148 m_dragging = true;
149 event->accept();
150}
151
152void MinimapWidget::wheelEvent(QWheelEvent *event)
153{
154 // Swallow wheel events to prevent zooming the main view when the cursor
155 // is over the minimap. Do not call base implementation.
156 event->accept();
157}
158
160{
161 // Start or restart the throttle timer. The actual cache invalidation
162 // and repaint will happen when the timer fires (max ~5 fps).
163 m_throttle.start();
164}
165
167{
168 m_cacheDirty = true;
169 update();
170}
171
173{
174 update();
175}
176
177bool MinimapWidget::eventFilter(QObject *watched, QEvent *event)
178{
179 if (watched == (m_view ? m_view->viewport() : nullptr) && event->type() == QEvent::Resize) {
181 return QWidget::eventFilter(watched, event);
182 }
183 return QWidget::eventFilter(watched, event);
184}
185
186bool MinimapWidget::computeTransform(QRectF &srcOut, double &scaleOut, double &dxOut, double &dyOut, double &usedWOut, double &usedHOut) const
187{
188 if (!m_scene)
189 return false;
190
191 // The minimap maps the whole navigable canvas, not the tight item box: fitting
192 // itemsBoundingRect() alone would zoom a single small element to fill the widget.
193 // - sceneRect() is the navigable area once the user has interacted (stable under scroll).
194 // - itemsBoundingRect() is a defensive superset so an item momentarily outside the scene
195 // rect stays visible.
196 // - the visible viewport region is the decisive term: right after a file loads, sceneRect()
197 // is set to the tight item box (see WorkSpace load), so without this the minimap would
198 // stay zoomed. Including it guarantees the minimap is never more zoomed-in than the view.
199 // Scene always contains a permanent (near-zero) rubber-band selection-rect item, so
200 // itemsBoundingRect() never reports invalid/empty here in practice -- the final early return
201 // exists only as a defensive guard for a Scene that isn't fully constructed the usual way.
202 QRectF src = m_scene->sceneRect().united(m_scene->cachedItemsBoundingRect());
203 if (m_view && m_view->viewport())
204 src = src.united(m_view->mapToScene(m_view->viewport()->rect()).boundingRect());
205 if (!src.isValid() || src.isEmpty())
206 return false;
207
208 const double w = static_cast<double>(width());
209 const double h = static_cast<double>(height());
210
211 // Expand the source to the widget's aspect ratio (growing the deficient axis symmetrically
212 // about its centre) so the scene fills the whole widget instead of letterboxing with bars.
213 // This stays aspect-correct -- it just shows a little extra empty canvas on the shorter axis,
214 // and unlike cropping it never clips the overview. src is valid/non-empty (guarded above), so
215 // its width/height are > 0.
216 const double widgetAspect = w / h;
217 const double srcAspect = src.width() / src.height();
218 if (srcAspect < widgetAspect) {
219 const double grow = (src.height() * widgetAspect - src.width()) / 2.0;
220 src.adjust(-grow, 0.0, grow, 0.0);
221 } else if (srcAspect > widgetAspect) {
222 const double grow = (src.width() / widgetAspect - src.height()) / 2.0;
223 src.adjust(0.0, -grow, 0.0, grow);
224 }
225
226 const double sx = src.width() > 0 ? w / src.width() : 1.0;
227 const double sy = src.height() > 0 ? h / src.height() : 1.0;
228 const double scale = qMin(sx, sy);
229 const double usedW = src.width() * scale;
230 const double usedH = src.height() * scale;
231 const double dx = (w - usedW) * 0.5;
232 const double dy = (h - usedH) * 0.5;
233
234 srcOut = src;
235 scaleOut = scale;
236 dxOut = dx;
237 dyOut = dy;
238 usedWOut = usedW;
239 usedHOut = usedH;
240 return true;
241}
242
243QPointF MinimapWidget::widgetToScene(const QPointF &p) const
244{
245 QRectF src;
246 double scale = 1.0, dx = 0.0, dy = 0.0, usedW = 0.0, usedH = 0.0;
247 if (!computeTransform(src, scale, dx, dy, usedW, usedH))
248 return QPointF();
249
250 double x = (p.x() - dx) / scale + src.left();
251 double y = (p.y() - dy) / scale + src.top();
252
253 // Clamp to source rect
254 x = qBound(src.left(), x, src.right());
255 y = qBound(src.top(), y, src.bottom());
256 return QPointF(x, y);
257}
258
259MinimapWidget::ResizeMode MinimapWidget::resizeModeAt(const QPoint &pos) const
260{
261 const int handleSize = 8;
262 const QRect r = rect();
263 const bool nearLeft = pos.x() <= handleSize;
264 const bool nearRight = pos.x() >= r.width() - handleSize;
265 const bool nearTop = pos.y() <= handleSize;
266 const bool nearBottom = pos.y() >= r.height() - handleSize;
267
268 if (nearTop && nearLeft) {
269 return ResizeMode::TopLeft;
270 }
271 if (nearTop && nearRight) {
272 return ResizeMode::TopRight;
273 }
274 if (nearBottom && nearLeft) {
275 return ResizeMode::BottomLeft;
276 }
277 if (nearBottom && nearRight) {
278 return ResizeMode::BottomRight;
279 }
280 if (nearLeft) {
281 return ResizeMode::Left;
282 }
283 if (nearRight) {
284 return ResizeMode::Right;
285 }
286 if (nearTop) {
287 return ResizeMode::Top;
288 }
289 if (nearBottom) {
290 return ResizeMode::Bottom;
291 }
292 return ResizeMode::None;
293}
294
295Qt::CursorShape MinimapWidget::cursorForResizeMode(ResizeMode mode) const
296{
297 switch (mode) {
298 case ResizeMode::Top:
299 case ResizeMode::Bottom:
300 return Qt::SizeVerCursor;
301 case ResizeMode::Left:
302 case ResizeMode::Right:
303 return Qt::SizeHorCursor;
304 case ResizeMode::TopLeft:
305 case ResizeMode::BottomRight:
306 return Qt::SizeFDiagCursor;
307 case ResizeMode::TopRight:
308 case ResizeMode::BottomLeft:
309 return Qt::SizeBDiagCursor;
310 default:
311 return Qt::ArrowCursor;
312 }
313}
314
315void MinimapWidget::applyResize(const QPoint &globalPos)
316{
317 const QPoint delta = globalPos - m_lastGlobalPos;
318 const QRect oldGeom = geometry();
319 QRect geom = oldGeom;
320
321 // Keep the widget's own aspect ratio stable while resizing -- based on the scene's
322 // content, same fallback chain as computeTransform() (itemsBoundingRect, then
323 // sceneRect), so a resize while empty/degenerate still has a sane 1:1 ratio.
324 const auto sceneAspectRatio = [this]() -> double {
325 QRectF src = m_scene ? m_scene->cachedItemsBoundingRect() : QRectF();
326 if (!src.isValid() || src.isEmpty())
327 src = m_scene ? m_scene->sceneRect() : QRectF();
328 if (!src.isValid() || src.width() <= 0.0 || src.height() <= 0.0)
329 return 1.0;
330 return src.width() / src.height();
331 };
332
333 const double aspect = sceneAspectRatio();
334 int newWidth = geom.width();
335 int newHeight = geom.height();
336
337 const bool top = m_resizeMode == ResizeMode::Top || m_resizeMode == ResizeMode::TopLeft || m_resizeMode == ResizeMode::TopRight;
338 const bool bottom = m_resizeMode == ResizeMode::Bottom || m_resizeMode == ResizeMode::BottomLeft || m_resizeMode == ResizeMode::BottomRight;
339 const bool left = m_resizeMode == ResizeMode::Left || m_resizeMode == ResizeMode::TopLeft || m_resizeMode == ResizeMode::BottomLeft;
340 const bool right = m_resizeMode == ResizeMode::Right || m_resizeMode == ResizeMode::TopRight || m_resizeMode == ResizeMode::BottomRight;
341
342 if ((top && right) || (top && left) || (bottom && right) || (bottom && left)) {
343 // Diagonal handle: follow whichever axis the cursor is moving faster along, derive
344 // the other from the locked aspect ratio.
345 const bool horizontalDominant = qAbs(delta.x()) >= qAbs(delta.y());
346 if (horizontalDominant) {
347 newWidth = left ? geom.width() - delta.x() : geom.width() + delta.x();
348 newHeight = qRound(newWidth / aspect);
349 } else {
350 newHeight = top ? geom.height() - delta.y() : geom.height() + delta.y();
351 newWidth = qRound(newHeight * aspect);
352 }
353 } else if (top || bottom) {
354 newHeight = top ? geom.height() - delta.y() : geom.height() + delta.y();
355 newWidth = qRound(newHeight * aspect);
356 } else if (left || right) {
357 newWidth = left ? geom.width() - delta.x() : geom.width() + delta.x();
358 newHeight = qRound(newWidth / aspect);
359 }
360
361 newWidth = qMax(minimumWidth(), newWidth);
362 newHeight = qMax(minimumHeight(), newHeight);
363 if (newWidth < minimumWidth()) {
364 newWidth = minimumWidth();
365 newHeight = qMax(minimumHeight(), qRound(newWidth / aspect));
366 }
367 if (newHeight < minimumHeight()) {
368 newHeight = minimumHeight();
369 newWidth = qMax(minimumWidth(), qRound(newHeight * aspect));
370 }
371
372 // Build the new rect directly from its intended edges rather than setSize() + setTop()/
373 // setLeft(): setSize() keeps the top-left corner fixed and recomputes bottom-right, so a
374 // subsequent setTop()/setLeft() call anchors on that already-shifted bottom-right instead
375 // of the original oldGeom -- doubling the resize delta and drifting the anchor edge on
376 // every top/left drag.
377 const int newX = left ? oldGeom.right() - newWidth + 1 : oldGeom.left();
378 const int newY = top ? oldGeom.bottom() - newHeight + 1 : oldGeom.top();
379 geom = QRect(newX, newY, newWidth, newHeight);
380
381 if (geom != oldGeom) {
382 setGeometry(geom);
383 }
384 m_lastGlobalPos = globalPos;
385}
386
387QRect MinimapWidget::moveHandleRect() const
388{
389 return QRect(0, 0, width(), 24);
390}
391
392bool MinimapWidget::isMoveHandle(const QPoint &pos) const
393{
394 return moveHandleRect().contains(pos);
395}
396
397void MinimapWidget::moveBy(const QPoint &delta)
398{
399 if (!parentWidget())
400 return;
401
402 const QPoint newPos = pos() + delta;
403 const QRect parentRect = parentWidget()->rect();
404 const int margin = 12;
405 const int x = qBound(margin, newPos.x(), parentRect.width() - width() - margin);
406 const int y = qBound(margin, newPos.y(), parentRect.height() - height() - margin);
407 move(x, y);
408}
409
410void MinimapWidget::drawMoveHandle(QPainter &painter) const
411{
412 const QRect handle = moveHandleRect();
413 const bool highlighted = m_moving || m_hoverMoveHandle;
414
415 QColor bg = highlighted ? palette().highlight().color() : palette().windowText().color();
416 bg.setAlpha(highlighted ? 90 : 35);
417 painter.fillRect(handle, bg);
418
419 // Grip dots (2 rows x 3 columns), the same affordance used for draggable strips/handles
420 // elsewhere (e.g. splitter/dock handles) -- signals "grab here" at a glance.
421 QColor dotColor = palette().windowText().color();
422 dotColor.setAlpha(highlighted ? 230 : 140);
423 painter.setPen(Qt::NoPen);
424 painter.setBrush(dotColor);
425
426 const QPointF center = handle.center();
427 constexpr double dotRadius = 1.2;
428 constexpr double spacingX = 6.0;
429 constexpr double spacingY = 5.0;
430 for (const double rowOffset : {-spacingY / 2.0, spacingY / 2.0}) {
431 for (const double colOffset : {-spacingX, 0.0, spacingX}) {
432 painter.drawEllipse(center + QPointF(colOffset, rowOffset), dotRadius, dotRadius);
433 }
434 }
435}
436
437void MinimapWidget::drawResizeGrips(QPainter &painter) const
438{
439 struct Corner {
440 ResizeMode mode;
441 QPoint origin;
442 int armX; // arm direction along x: +1 rightwards, -1 leftwards
443 int armY; // arm direction along y: +1 downwards, -1 upwards
444 };
445
446 constexpr int inset = 3;
447 constexpr int armLength = 9;
448 const QRect r = rect();
449 const Corner corners[] = {
450 {ResizeMode::TopLeft, QPoint(inset, inset), 1, 1},
451 {ResizeMode::TopRight, QPoint(r.width() - 1 - inset, inset), -1, 1},
452 {ResizeMode::BottomLeft, QPoint(inset, r.height() - 1 - inset), 1, -1},
453 {ResizeMode::BottomRight, QPoint(r.width() - 1 - inset, r.height() - 1 - inset), -1, -1},
454 };
455
456 for (const Corner &corner : corners) {
457 const bool highlighted = m_resizing ? m_resizeMode == corner.mode : m_hoverResizeMode == corner.mode;
458 QColor color = highlighted ? palette().highlight().color() : palette().windowText().color();
459 color.setAlpha(highlighted ? 230 : 130);
460 QPen pen(color);
461 pen.setWidthF(highlighted ? 2.0 : 1.4);
462 painter.setPen(pen);
463 painter.drawLine(corner.origin, corner.origin + QPoint(corner.armX * armLength, 0));
464 painter.drawLine(corner.origin, corner.origin + QPoint(0, corner.armY * armLength));
465 }
466}
467
468void MinimapWidget::mouseMoveEvent(QMouseEvent *event)
469{
470 if (m_resizing) {
471 applyResize(event->globalPosition().toPoint());
473 event->accept();
474 return;
475 }
476
477 if (m_moving) {
478 moveBy(event->globalPosition().toPoint() - m_lastGlobalPos);
479 m_lastGlobalPos = event->globalPosition().toPoint();
480 event->accept();
481 return;
482 }
483
484 if (!m_dragging) {
485 updateHoverState(event->pos());
486 }
487
488 if (!m_dragging || !m_scene || !m_view) {
489 QWidget::mouseMoveEvent(event);
490 return;
491 }
492
493 m_view->centerOn(widgetToScene(event->pos()));
495 event->accept();
496}
497
498void MinimapWidget::mouseReleaseEvent(QMouseEvent *event)
499{
500 if (m_resizing) {
501 m_resizing = false;
502 m_resizeMode = ResizeMode::None;
503 releaseMouse();
504 event->accept();
505 emit geometryChangeFinished(geometry());
506 return;
507 }
508 if (m_moving) {
509 m_moving = false;
510 releaseMouse();
511 event->accept();
512 emit geometryChangeFinished(geometry());
513 return;
514 }
515 if (m_dragging) {
516 m_dragging = false;
517 event->accept();
518 return;
519 }
520 QWidget::mouseReleaseEvent(event);
521}
522
523void MinimapWidget::enterEvent(QEnterEvent *event)
524{
525 QWidget::enterEvent(event);
526 updateHoverState(mapFromGlobal(QCursor::pos()));
527}
528
529void MinimapWidget::leaveEvent(QEvent *event)
530{
531 QWidget::leaveEvent(event);
532 unsetCursor();
533 if (m_hoverResizeMode != ResizeMode::None || m_hoverMoveHandle) {
534 m_hoverResizeMode = ResizeMode::None;
535 m_hoverMoveHandle = false;
536 update();
537 }
538}
539
540void MinimapWidget::updateHoverState(const QPoint &pos)
541{
542 const ResizeMode mode = resizeModeAt(pos);
543 const bool onMoveHandle = mode == ResizeMode::None && isMoveHandle(pos);
544
545 if (mode != ResizeMode::None) {
546 setCursor(cursorForResizeMode(mode));
547 } else if (onMoveHandle) {
548 setCursor(Qt::OpenHandCursor);
549 } else {
550 unsetCursor();
551 }
552
553 if (mode != m_hoverResizeMode || onMoveHandle != m_hoverMoveHandle) {
554 m_hoverResizeMode = mode;
555 m_hoverMoveHandle = onMoveHandle;
556 update(); // repaint so the corner/move-strip highlight tracks the hover state
557 }
558}
Extended QGraphicsView with zoom, pan, and fast-rendering modes.
Main circuit editing scene with undo/redo and user interaction.
Theme management types and singleton ThemeManager.
Extended QGraphicsView with enhanced navigation capabilities.
void zoomChanged()
Emitted whenever the zoom level changes.
void mouseMoveEvent(QMouseEvent *event) override
void paintEvent(QPaintEvent *event) override
void wheelEvent(QWheelEvent *event) override
void mouseReleaseEvent(QMouseEvent *event) override
void leaveEvent(QEvent *event) override
bool eventFilter(QObject *watched, QEvent *event) override
void geometryChangeFinished(const QRect &geometry)
void enterEvent(QEnterEvent *event) override
void mousePressEvent(QMouseEvent *event) override
QSize sizeHint() const override
MinimapWidget(Scene *scene, GraphicsView *view, QWidget *parent=nullptr)
Main circuit editing scene.
Definition Scene.h:56
QRectF cachedItemsBoundingRect() const
Definition Scene.cpp:527
void circuitHasChanged()
Emitted whenever the circuit changes (element added/removed/moved).
static ThemeManager & instance()
Returns the singleton ThemeManager instance.
void themeChanged()
Emitted whenever the active theme changes.