wiRedPanda
Logic Circuit Simulator
Loading...
Searching...
No Matches
TourOverlay.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 <QApplication>
7#include <QEvent>
8#include <QFontInfo>
9#include <QFrame>
10#include <QHBoxLayout>
11#include <QLabel>
12#include <QPainter>
13#include <QPainterPath>
14#include <QPen>
15#include <QPushButton>
16#include <QVBoxLayout>
17
19#include "App/Tour/TourEngine.h"
20
21static constexpr int kCalloutWidth = 360;
22static constexpr int kSpotlightPad = 6;
23static constexpr int kCalloutMargin = 12;
24
25TourOverlay::TourOverlay(TourEngine *engine, QWidget *parent)
26 : QWidget(parent)
27 , m_engine(engine)
28{
29 // Cover the entire parent from the start
30 if (parent) {
31 resize(parent->size());
32 parent->installEventFilter(this);
33 }
34
35 setupUi();
36
37 connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, &TourOverlay::applyTheme);
38}
39
41{
42 m_resolver = std::move(resolver);
43}
44
45void TourOverlay::setParentWindow(QWidget *newParent)
46{
47 if (parentWidget()) {
48 parentWidget()->removeEventFilter(this);
49 }
50 setParent(newParent);
51 if (newParent) {
52 resize(newParent->size());
53 newParent->installEventFilter(this);
54 }
55}
56
57void TourOverlay::setupUi()
58{
59 // --- Callout panel ---
60 m_callout = new QFrame(this);
61 m_callout->setFixedWidth(kCalloutWidth);
62
63 auto *layout = new QVBoxLayout(m_callout);
64 layout->setContentsMargins(14, 14, 14, 14);
65 layout->setSpacing(8);
66
67 // Top row: step counter
68 auto *topRow = new QHBoxLayout;
69 m_stepCounter = new QLabel(m_callout);
70 topRow->addWidget(m_stepCounter);
71 topRow->addStretch();
72 layout->addLayout(topRow);
73
74 m_titleLabel = new QLabel(m_callout);
75 m_titleLabel->setWordWrap(true);
76 layout->addWidget(m_titleLabel);
77
78 m_bodyLabel = new QLabel(m_callout);
79 m_bodyLabel->setWordWrap(true);
80 layout->addWidget(m_bodyLabel);
81
82 // Navigation row: [Exit] [← Back] ←stretch→ [Next →]
83 auto *btnRow = new QHBoxLayout;
84 btnRow->setSpacing(6);
85
86 m_closeButton = new QPushButton(tr("Exit"), m_callout);
87 m_closeButton->setToolTip(tr("Stop the tour"));
88
89 m_prevButton = new QPushButton(tr("← Back"), m_callout);
90
91 m_nextButton = new QPushButton(tr("Next →"), m_callout);
92
93 btnRow->addWidget(m_closeButton);
94 btnRow->addWidget(m_prevButton);
95 btnRow->addStretch();
96 btnRow->addWidget(m_nextButton);
97 layout->addLayout(btnRow);
98
99 m_callout->adjustSize();
100
101 connect(m_closeButton, &QPushButton::clicked, this, &TourOverlay::closeRequested);
102 connect(m_prevButton, &QPushButton::clicked, this, [this] { m_engine->goToPreviousStep(); });
103 connect(m_nextButton, &QPushButton::clicked, this, [this] { m_engine->advanceStep(); });
104
105 applyTheme();
106}
107
108int TourOverlay::scaledFontPx(int basePx)
109{
110 constexpr int baselinePx = 13;
111 const int currentPx = QFontInfo(QApplication::font()).pixelSize();
112 return qRound(static_cast<double>(basePx) * currentPx / baselinePx);
113}
114
115void TourOverlay::applyTheme()
116{
117 const bool dark = (ThemeManager::effectiveTheme() == Theme::Dark);
118 const int navFontPx = scaledFontPx(13);
119 const int counterFontPx = scaledFontPx(12);
120 const int titleFontPx = scaledFontPx(16);
121 const int bodyFontPx = scaledFontPx(14);
122
123 if (dark) {
124 m_dimColor = QColor(0, 0, 0, 160);
125 m_spotlightColor = QColor(255, 200, 50, 220);
126
127 m_callout->setStyleSheet(
128 QString("QFrame { background: rgba(20,20,20,240); border: 1px solid rgba(255,200,50,120); border-radius: 8px; } QLabel { background: transparent; border: none; } QPushButton { border-radius: 4px; padding: 4px 12px; font-size: %1px; }").arg(navFontPx)
129 );
130 m_stepCounter->setStyleSheet(QString("color: rgba(255,200,50,200); font-size: %1px;").arg(counterFontPx));
131 m_titleLabel->setStyleSheet(QString("color: white; font-size: %1px; font-weight: bold;").arg(titleFontPx));
132 m_bodyLabel->setStyleSheet(QString("color: rgba(220,220,220,230); font-size: %1px;").arg(bodyFontPx));
133
134 const QString navStyle =
135 QString("QPushButton { color: white; background: rgba(255,255,255,40); border: 1px solid rgba(255,255,255,80); border-radius: 4px; padding: 4px 12px; font-size: %1px; } QPushButton:hover { background: rgba(255,255,255,60); } QPushButton:disabled { color: rgba(255,255,255,80); background: rgba(80,80,80,60); }").arg(navFontPx);
136 m_closeButton->setStyleSheet(navStyle);
137 m_prevButton->setStyleSheet(navStyle);
138 m_nextButton->setStyleSheet(
139 QString("QPushButton { color: white; background: rgba(255,160,20,200); border: 1px solid rgba(255,200,50,150); border-radius: 4px; padding: 4px 12px; font-size: %1px; } QPushButton:hover { background: rgba(255,180,40,230); }").arg(navFontPx));
140 } else {
141 m_dimColor = QColor(0, 0, 0, 90);
142 m_spotlightColor = QColor(0, 120, 210, 220);
143
144 m_callout->setStyleSheet(
145 QString("QFrame { background: rgba(255,255,255,240); border: 1px solid rgba(0,100,200,150); border-radius: 8px; } QLabel { background: transparent; border: none; } QPushButton { border-radius: 4px; padding: 4px 12px; font-size: %1px; }").arg(navFontPx)
146 );
147 m_stepCounter->setStyleSheet(QString("color: rgba(0,100,200,200); font-size: %1px;").arg(counterFontPx));
148 m_titleLabel->setStyleSheet(QString("color: rgb(20,20,20); font-size: %1px; font-weight: bold;").arg(titleFontPx));
149 m_bodyLabel->setStyleSheet(QString("color: rgba(50,50,50,230); font-size: %1px;").arg(bodyFontPx));
150
151 const QString navStyle =
152 QString("QPushButton { color: rgb(20,20,20); background: rgba(0,0,0,20); border: 1px solid rgba(0,0,0,80); border-radius: 4px; padding: 4px 12px; font-size: %1px; } QPushButton:hover { background: rgba(0,0,0,40); } QPushButton:disabled { color: rgba(0,0,0,60); background: rgba(0,0,0,10); }").arg(navFontPx);
153 m_closeButton->setStyleSheet(navStyle);
154 m_prevButton->setStyleSheet(navStyle);
155 m_nextButton->setStyleSheet(
156 QString("QPushButton { color: white; background: rgba(0,120,210,200); border: 1px solid rgba(0,100,200,150); border-radius: 4px; padding: 4px 12px; font-size: %1px; } QPushButton:hover { background: rgba(0,140,230,230); }").arg(navFontPx));
157 }
158
159 update();
160}
161
162bool TourOverlay::eventFilter(QObject *obj, QEvent *event)
163{
164 if (obj == parentWidget() && event->type() == QEvent::Resize) {
165 resize(parentWidget()->size());
166 repositionCallout();
167 }
168 return QWidget::eventFilter(obj, event);
169}
170
171void TourOverlay::onStepChanged(int step, int total, const TourStep &stepData)
172{
173 m_callout->hide();
174
175 m_stepCounter->setText(tr("Step %1 of %2").arg(step + 1).arg(total));
176 m_titleLabel->setText(stepData.title);
177 m_bodyLabel->setText(stepData.body);
178
179 m_prevButton->setEnabled(step > 0);
180 m_nextButton->setText(step == total - 1 ? tr("Finish") : tr("Next →"));
181
182 m_currentTarget = stepData.target;
183 m_highlightRect = QRect{};
184 if (m_resolver && !stepData.target.isEmpty() && stepData.target != "none") {
185 m_highlightRect = m_resolver(stepData.target);
186 }
187
188 m_callout->adjustSize();
189 repositionCallout();
190 // Use update() (deferred) instead of repaint() (synchronous) so that all
191 // pending QEvent::LayoutRequest events are processed before paintEvent
192 // re-resolves the spotlight geometry.
193 update();
194 m_callout->show();
195}
196
198{
199 const TourStep &stepData = m_engine->currentStepData();
200 const int step = m_engine->currentStep();
201 const int total = m_engine->totalSteps();
202
203 m_stepCounter->setText(tr("Step %1 of %2").arg(step + 1).arg(total));
204 m_titleLabel->setText(stepData.title);
205 m_bodyLabel->setText(stepData.body);
206 m_nextButton->setText(step == total - 1 ? tr("Finish") : tr("Next →"));
207
208 m_closeButton->setText(tr("Exit"));
209 m_closeButton->setToolTip(tr("Stop the tour"));
210 m_prevButton->setText(tr("← Back"));
211
212 m_callout->adjustSize();
213 repositionCallout();
214 update();
215}
216
218{
219 hide();
220 emit closeRequested();
221}
222
223void TourOverlay::repositionCallout()
224{
225 const int pad = kCalloutMargin;
226 const int cw = m_callout->width();
227 const int ch = m_callout->height();
228 const int ow = width();
229 const int oh = height();
230
231 if (m_highlightRect.isEmpty()) {
232 // Center the callout in the overlay
233 m_callout->move((ow - cw) / 2, (oh - ch) / 2);
234 return;
235 }
236
237 const QRect spot = m_highlightRect.adjusted(-kSpotlightPad, -kSpotlightPad,
239
240 // Try below the spotlight
241 int cx = qBound(pad, spot.left() + (spot.width() - cw) / 2, ow - cw - pad);
242 int cy = spot.bottom() + pad;
243
244 if (cy + ch + pad > oh) {
245 // Not enough room below — try above
246 cy = spot.top() - ch - pad;
247 }
248
249 if (cy < pad) {
250 // No room above either — center vertically, shift right of spotlight
251 cy = (oh - ch) / 2;
252 cx = qMin(spot.right() + pad, ow - cw - pad);
253 if (cx + cw + pad > ow) { // LCOV_EXCL_LINE — unreachable by construction: cx is qMin(A, ow-cw-pad), so cx+cw+pad <= (ow-cw-pad)+cw+pad == ow always, regardless of which side of the min wins.
254 cx = qMax(pad, spot.left() - cw - pad); // LCOV_EXCL_LINE — see above.
255 }
256 }
257
258 m_callout->move(cx, qBound(pad, cy, oh - ch - pad));
259}
260
261void TourOverlay::paintEvent(QPaintEvent *event)
262{
263 Q_UNUSED(event)
264
265 // Re-resolve at paint time: Qt guarantees all QEvent::LayoutRequest events
266 // are processed before paintEvent fires, so the geometry is always settled.
267 if (m_resolver && !m_currentTarget.isEmpty() && m_currentTarget != "none") {
268 const QRect liveRect = m_resolver(m_currentTarget);
269 if (liveRect != m_highlightRect) {
270 m_highlightRect = liveRect;
271 repositionCallout();
272 }
273 }
274
275 QPainter p(this);
276 p.setRenderHint(QPainter::Antialiasing);
277
278 if (m_highlightRect.isEmpty()) {
279 p.fillRect(rect(), m_dimColor);
280 return;
281 }
282
283 const QRect spot = m_highlightRect.adjusted(-kSpotlightPad, -kSpotlightPad,
285
286 QPainterPath full;
287 full.addRect(rect());
288 QPainterPath hole;
289 hole.addRoundedRect(spot, 6, 6);
290 p.fillPath(full.subtracted(hole), m_dimColor);
291
292 p.setBrush(Qt::NoBrush);
293 p.setPen(QPen(m_spotlightColor, 2));
294 p.drawRoundedRect(spot, 6, 6);
295}
Theme management types and singleton ThemeManager.
static constexpr int kCalloutMargin
static constexpr int kSpotlightPad
static constexpr int kCalloutWidth
static ThemeManager & instance()
Returns the singleton ThemeManager instance.
void themeChanged()
Emitted whenever the active theme changes.
static Theme effectiveTheme()
Returns the effective theme (Light or Dark), resolving System to the OS preference.
void goToPreviousStep()
void onTourFinished()
void setTargetResolver(TargetResolver resolver)
TourOverlay(TourEngine *engine, QWidget *parent=nullptr)
void closeRequested()
void onRetranslated()
void paintEvent(QPaintEvent *event) override
void onStepChanged(int step, int total, const TourStep &stepData)
void setParentWindow(QWidget *newParent)
Re-parents the overlay to newParent and reinstalls the resize event filter.
std::function< QRect(const QString &targetId)> TargetResolver
Definition TourOverlay.h:30
bool eventFilter(QObject *obj, QEvent *event) override
QString body
Definition TourStep.h:17
QString title
Definition TourStep.h:16
QString target
Definition TourStep.h:18