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 {"
129 " background: rgba(20,20,20,240);"
130 " border: 1px solid rgba(255,200,50,120);"
131 " border-radius: 8px;"
132 "}"
133 "QLabel { background: transparent; border: none; }"
134 "QPushButton { border-radius: 4px; padding: 4px 12px; font-size: %1px; }")
135 .arg(navFontPx)
136 );
137 m_stepCounter->setStyleSheet(QString("color: rgba(255,200,50,200); font-size: %1px;").arg(counterFontPx));
138 m_titleLabel->setStyleSheet(QString("color: white; font-size: %1px; font-weight: bold;").arg(titleFontPx));
139 m_bodyLabel->setStyleSheet(QString("color: rgba(220,220,220,230); font-size: %1px;").arg(bodyFontPx));
140
141 const QString navStyle =
142 QString("QPushButton { color: white; background: rgba(255,255,255,40);"
143 " border: 1px solid rgba(255,255,255,80); border-radius: 4px;"
144 " padding: 4px 12px; font-size: %1px; }"
145 "QPushButton:hover { background: rgba(255,255,255,60); }"
146 "QPushButton:disabled { color: rgba(255,255,255,80); background: rgba(80,80,80,60); }")
147 .arg(navFontPx);
148 m_closeButton->setStyleSheet(navStyle);
149 m_prevButton->setStyleSheet(navStyle);
150 m_nextButton->setStyleSheet(
151 QString("QPushButton { color: white; background: rgba(255,160,20,200);"
152 " border: 1px solid rgba(255,200,50,150); border-radius: 4px;"
153 " padding: 4px 12px; font-size: %1px; }"
154 "QPushButton:hover { background: rgba(255,180,40,230); }")
155 .arg(navFontPx));
156 } else {
157 m_dimColor = QColor(0, 0, 0, 90);
158 m_spotlightColor = QColor(0, 120, 210, 220);
159
160 m_callout->setStyleSheet(
161 QString("QFrame {"
162 " background: rgba(255,255,255,240);"
163 " border: 1px solid rgba(0,100,200,150);"
164 " border-radius: 8px;"
165 "}"
166 "QLabel { background: transparent; border: none; }"
167 "QPushButton { border-radius: 4px; padding: 4px 12px; font-size: %1px; }")
168 .arg(navFontPx)
169 );
170 m_stepCounter->setStyleSheet(QString("color: rgba(0,100,200,200); font-size: %1px;").arg(counterFontPx));
171 m_titleLabel->setStyleSheet(QString("color: rgb(20,20,20); font-size: %1px; font-weight: bold;").arg(titleFontPx));
172 m_bodyLabel->setStyleSheet(QString("color: rgba(50,50,50,230); font-size: %1px;").arg(bodyFontPx));
173
174 const QString navStyle =
175 QString("QPushButton { color: rgb(20,20,20); background: rgba(0,0,0,20);"
176 " border: 1px solid rgba(0,0,0,80); border-radius: 4px;"
177 " padding: 4px 12px; font-size: %1px; }"
178 "QPushButton:hover { background: rgba(0,0,0,40); }"
179 "QPushButton:disabled { color: rgba(0,0,0,60); background: rgba(0,0,0,10); }")
180 .arg(navFontPx);
181 m_closeButton->setStyleSheet(navStyle);
182 m_prevButton->setStyleSheet(navStyle);
183 m_nextButton->setStyleSheet(
184 QString("QPushButton { color: white; background: rgba(0,120,210,200);"
185 " border: 1px solid rgba(0,100,200,150); border-radius: 4px;"
186 " padding: 4px 12px; font-size: %1px; }"
187 "QPushButton:hover { background: rgba(0,140,230,230); }")
188 .arg(navFontPx));
189 }
190
191 update();
192}
193
194bool TourOverlay::eventFilter(QObject *obj, QEvent *event)
195{
196 if (obj == parentWidget() && event->type() == QEvent::Resize) {
197 resize(parentWidget()->size());
198 repositionCallout();
199 }
200 return QWidget::eventFilter(obj, event);
201}
202
203void TourOverlay::onStepChanged(int step, int total, const TourStep &stepData)
204{
205 m_callout->hide();
206
207 m_stepCounter->setText(tr("Step %1 of %2").arg(step + 1).arg(total));
208 m_titleLabel->setText(stepData.title);
209 m_bodyLabel->setText(stepData.body);
210
211 m_prevButton->setEnabled(step > 0);
212 m_nextButton->setText(step == total - 1 ? tr("Finish") : tr("Next →"));
213
214 m_currentTarget = stepData.target;
215 m_highlightRect = QRect{};
216 if (m_resolver && !stepData.target.isEmpty() && stepData.target != "none") {
217 m_highlightRect = m_resolver(stepData.target);
218 }
219
220 m_callout->adjustSize();
221 repositionCallout();
222 // Use update() (deferred) instead of repaint() (synchronous) so that all
223 // pending QEvent::LayoutRequest events are processed before paintEvent
224 // re-resolves the spotlight geometry.
225 update();
226 m_callout->show();
227}
228
230{
231 const TourStep &stepData = m_engine->currentStepData();
232 const int step = m_engine->currentStep();
233 const int total = m_engine->totalSteps();
234
235 m_stepCounter->setText(tr("Step %1 of %2").arg(step + 1).arg(total));
236 m_titleLabel->setText(stepData.title);
237 m_bodyLabel->setText(stepData.body);
238 m_nextButton->setText(step == total - 1 ? tr("Finish") : tr("Next →"));
239
240 m_closeButton->setText(tr("Exit"));
241 m_closeButton->setToolTip(tr("Stop the tour"));
242 m_prevButton->setText(tr("← Back"));
243
244 m_callout->adjustSize();
245 repositionCallout();
246 update();
247}
248
250{
251 hide();
252 emit closeRequested();
253}
254
255void TourOverlay::repositionCallout()
256{
257 const int pad = kCalloutMargin;
258 const int cw = m_callout->width();
259 const int ch = m_callout->height();
260 const int ow = width();
261 const int oh = height();
262
263 if (m_highlightRect.isEmpty()) {
264 // Center the callout in the overlay
265 m_callout->move((ow - cw) / 2, (oh - ch) / 2);
266 return;
267 }
268
269 const QRect spot = m_highlightRect.adjusted(-kSpotlightPad, -kSpotlightPad,
271
272 // Try below the spotlight
273 int cx = qBound(pad, spot.left() + (spot.width() - cw) / 2, ow - cw - pad);
274 int cy = spot.bottom() + pad;
275
276 if (cy + ch + pad > oh) {
277 // Not enough room below — try above
278 cy = spot.top() - ch - pad;
279 }
280
281 if (cy < pad) {
282 // No room above either — center vertically, shift right of spotlight
283 cy = (oh - ch) / 2;
284 cx = qMin(spot.right() + pad, ow - cw - pad);
285 if (cx + cw + pad > ow) {
286 cx = qMax(pad, spot.left() - cw - pad);
287 }
288 }
289
290 m_callout->move(cx, qBound(pad, cy, oh - ch - pad));
291}
292
293void TourOverlay::paintEvent(QPaintEvent *event)
294{
295 Q_UNUSED(event)
296
297 // Re-resolve at paint time: Qt guarantees all QEvent::LayoutRequest events
298 // are processed before paintEvent fires, so the geometry is always settled.
299 if (m_resolver && !m_currentTarget.isEmpty() && m_currentTarget != "none") {
300 const QRect liveRect = m_resolver(m_currentTarget);
301 if (liveRect != m_highlightRect) {
302 m_highlightRect = liveRect;
303 repositionCallout();
304 }
305 }
306
307 QPainter p(this);
308 p.setRenderHint(QPainter::Antialiasing);
309
310 if (m_highlightRect.isEmpty()) {
311 p.fillRect(rect(), m_dimColor);
312 return;
313 }
314
315 const QRect spot = m_highlightRect.adjusted(-kSpotlightPad, -kSpotlightPad,
317
318 QPainterPath full;
319 full.addRect(rect());
320 QPainterPath hole;
321 hole.addRoundedRect(spot, 6, 6);
322 p.fillPath(full.subtracted(hole), m_dimColor);
323
324 p.setBrush(Qt::NoBrush);
325 p.setPen(QPen(m_spotlightColor, 2));
326 p.drawRoundedRect(spot, 6, 6);
327}
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