blockview.cpp raw
1 // Copyright (c) 2024 Luke Dashjr
2 // Distributed under the MIT software license, see the accompanying
3 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
4
5 #include <limenka-build-config.h> // IWYU pragma: keep
6
7 #include <qt/blockview.h>
8
9 #include <addresstype.h>
10 #include <interfaces/node.h>
11 #include <key_io.h>
12 #include <logging.h>
13 #include <node/context.h>
14 #include <node/miner.h>
15 #include <primitives/block.h>
16 #include <util/strencodings.h>
17 #include <validation.h>
18 #include <validationinterface.h>
19
20 #include <qt/limenkaunits.h>
21 #include <qt/clientmodel.h>
22 #include <qt/guiutil.h>
23 #include <qt/networkstyle.h>
24 #include <qt/optionsmodel.h>
25
26 #include <cmath>
27 #include <numbers>
28
29 #include <QColor>
30 #include <QComboBox>
31 #include <QLabel>
32 #include <QGraphicsEllipseItem>
33 #include <QGraphicsScene>
34 #include <QGraphicsView>
35 #include <QMouseEvent>
36 #include <QPalette>
37 #include <QHBoxLayout>
38 #include <QToolTip>
39 #include <QVariant>
40 #include <QVBoxLayout>
41
42 Q_DECLARE_METATYPE(CTransactionRef)
43
44 static constexpr qreal TX_PADDING_NEXT{4};
45 static constexpr qreal TX_PADDING_NEARBY{2};
46 static constexpr qreal EXPECTED_WHITESPACE_PERCENT{1.5};
47 static constexpr auto RADIAN_DIVISOR{8};
48
49 void ScalingGraphicsView::mouseMoveEvent(QMouseEvent * const event)
50 {
51 auto * const gi = itemAt(event->pos());
52 const auto tx = gi ? gi->data(0).value<CTransactionRef>() : CTransactionRef();
53 if (!tx) {
54 QToolTip::showText(QPoint{}, QStringLiteral(""), nullptr, {}, 0);
55 return;
56 }
57
58 QString tx_info_str = "<qt>" + QString::fromStdString(tx->GetHash().ToString()) + "<br>";
59 tx_info_str += "<br>" + tr("Size: %1 bytes").arg(tx->GetTotalSize());
60 tx_info_str += "<br>Outputs:<div style=\"margin-left:4ex;margin-top:0;padding-top:0\">";
61
62 LimenkaUnit unit;
63 QFont font_for_money;
64 if (auto* options_model = (m_bv && m_bv->m_client_model) ? m_bv->m_client_model->getOptionsModel() : nullptr; options_model) {
65 unit = options_model->getDisplayUnit();
66 font_for_money = options_model->getFontForMoney(unit);
67 } else {
68 unit = LimenkaUnit::BTC;
69 }
70 int i = 0;
71 for (const auto& txout : tx->vout) {
72 ++i;
73 CTxDestination dest;
74 QString address;
75 if (ExtractDestination(txout.scriptPubKey, dest)) {
76 address = GUIUtil::HtmlEscape(EncodeDestination(dest));
77 } else {
78 address = tr("(unknown)");
79 }
80 auto amount_str = LimenkaUnits::formatHtmlWithUnit(font_for_money, unit, txout.nValue);
81 if (i > 1) tx_info_str += "<br>";
82 tx_info_str += tr("#%1: %2 to %3").arg(i).arg(amount_str).arg(address);
83 }
84 tx_info_str += "</div></qt>";
85 #if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
86 const QPoint event_global_pos = event->globalPosition().toPoint();
87 #else
88 const QPoint event_global_pos = event->globalPos();
89 #endif
90 QToolTip::showText(event_global_pos, tx_info_str, this, {}, std::numeric_limits<int>::max());
91 }
92
93 void ScalingGraphicsView::resizeEvent(QResizeEvent * const event)
94 {
95 fitInView(scene()->sceneRect(), Qt::KeepAspectRatio);
96 QGraphicsView::resizeEvent(event);
97 }
98
99 bool ScalingGraphicsView::viewportEvent(QEvent * const event)
100 {
101 if (event->type() == QEvent::ToolTip) {
102 // causes QGraphicsScene to destroy our tooltips, so block it here
103 return true;
104 }
105 return QGraphicsView::viewportEvent(event);
106 }
107
108 class BlockViewValidationInterface final : public CValidationInterface
109 {
110 private:
111 GuiBlockView& m_bv;
112
113 public:
114 explicit BlockViewValidationInterface(GuiBlockView& bv) : m_bv(bv) {}
115
116 void BlockConnected(ChainstateRole role, const std::shared_ptr<const CBlock>& block_cached, const CBlockIndex* pblockindex) override {
117 static_assert(std::is_same<int, decltype(pblockindex->nHeight)>::value, "nHeight type assumption does not hold");
118 QMetaObject::invokeMethod(&m_bv, "updateBestBlock", Qt::QueuedConnection, Q_ARG(int, pblockindex->nHeight));
119
120 if (!m_bv.m_follow_tip) return;
121
122 std::shared_ptr<const CBlock> block = block_cached;
123 auto chainman = m_bv.getChainstateManager();
124 Assert(chainman);
125 if (!block) {
126 std::shared_ptr<CBlock> pblock = std::make_shared<CBlock>();
127 if (!chainman->m_blockman.ReadBlock(*pblock, *pblockindex)) {
128 // Indicate error somehow?
129 return;
130 }
131 block = pblock;
132 }
133
134 const auto block_subsidy = GetBlockSubsidy(pblockindex->nHeight, chainman->GetParams().GetConsensus());
135
136 m_bv.setBlock(block, block_subsidy);
137 }
138
139 void NewBlockTemplate(const std::shared_ptr<node::CBlockTemplate>& blocktemplate) override {
140 {
141 LOCK(m_bv.m_mutex);
142 if (m_bv.m_block) {
143 // Update cached template, but don't render it
144 m_bv.m_block_template = blocktemplate;
145 return;
146 }
147 }
148
149 m_bv.setBlock(blocktemplate);
150 }
151 };
152
153 void GuiBlockView::updateBestBlock(const int height)
154 {
155 m_block_chooser->setItemText(1, tr("Newest block (%1)").arg(height));
156 }
157
158 GuiBlockView::GuiBlockView(const PlatformStyle *platformStyle, const NetworkStyle *networkStyle, QWidget *parent) :
159 QDialog(parent, GUIUtil::dialog_flags | Qt::WindowMaximizeButtonHint)
160 {
161 setWindowTitle(tr(CLIENT_NAME) + " - " + tr("Block View") + " " + networkStyle->getTitleAddText());
162 setWindowIcon(networkStyle->getTrayAndWindowIcon());
163 resize(640, 640);
164
165 updateThemeColors();
166
167 QVBoxLayout * const layout = new QVBoxLayout(this);
168 setLayout(layout);
169
170 auto hlayout = new QHBoxLayout;
171 layout->addLayout(hlayout);
172 hlayout->addWidget(new QLabel(tr("Displayed block: ")));
173 m_block_chooser = new QComboBox(this);
174 hlayout->addWidget(m_block_chooser, 1);
175 connect(m_block_chooser, QOverload<int>::of(&QComboBox::currentIndexChanged), [=, this](const int index){
176 m_follow_tip = false;
177 auto ud = m_block_chooser->itemData(index).toInt();
178 if (ud == -3) {
179 m_block_chooser->setEditable(false);
180 auto block_template = WITH_LOCK(m_mutex, return m_block_template);
181 if (block_template) {
182 setBlock(block_template);
183 } else {
184 clear();
185 }
186 return;
187 }
188
189 auto chainman = getChainstateManager();
190 if (!chainman) {
191 clear();
192 return;
193 }
194 auto& blockman = chainman->m_blockman;
195
196 CBlockIndex *pblockindex;
197 if (ud == -2) {
198 m_follow_tip = true;
199 pblockindex = WITH_LOCK(::cs_main, return chainman->ActiveChain().Tip());
200 if (!pblockindex) {
201 clear();
202 return;
203 }
204 } else if (ud == -1) {
205 m_block_chooser->setEditable(true);
206 m_block_chooser->clearEditText();
207 return;
208 } else {
209 auto qtxt = m_block_chooser->itemText(index);
210 auto txt = qtxt.toStdString();
211 auto blockhash{uint256::FromHex(txt)};
212 if (blockhash) {
213 LOCK(cs_main);
214 pblockindex = blockman.LookupBlockIndex(*blockhash);
215 } else if (auto height = ToIntegral<int>(txt)) {
216 LOCK(cs_main);
217 pblockindex = chainman->ActiveChain()[*height];
218 } else {
219 pblockindex = nullptr;
220 }
221 if (!pblockindex) {
222 clear();
223 QMessageBox::critical(this, tr("Invalid block"), tr("\"%1\" is not a valid block height or hash!").arg(qtxt));
224 m_block_chooser->removeItem(index);
225 return;
226 }
227 }
228
229 std::shared_ptr<CBlock> block = std::make_shared<CBlock>();
230 if ((!blockman.ReadBlock(*block, *pblockindex)) || block->vtx.empty()) {
231 clear();
232 const bool is_pruned = WITH_LOCK(::cs_main, return blockman.IsBlockPruned(*pblockindex));
233 if (is_pruned) {
234 QMessageBox::critical(this, tr("Pruned block"), tr("Block %1 (%2) is pruned.").arg(pblockindex->nHeight).arg(QString::fromStdString(pblockindex->GetBlockHash().ToString())));
235 } else {
236 QMessageBox::critical(this, tr("Error reading block"), tr("Block %1 (%2) could not be loaded.").arg(pblockindex->nHeight).arg(QString::fromStdString(pblockindex->GetBlockHash().ToString())));
237 }
238 m_block_chooser->removeItem(index);
239 return;
240 }
241
242 m_block_chooser->setEditable(false);
243
244 const auto block_subsidy = GetBlockSubsidy(pblockindex->nHeight, chainman->GetParams().GetConsensus());
245
246 setBlock(block, block_subsidy);
247 });
248 // Items initialized later, after ClientModel is available
249
250 m_scene = new QGraphicsScene(this);
251 m_scene->setSceneRect(0, 0, 1, 1);
252 auto view = new ScalingGraphicsView(m_scene, this);
253 view->m_bv = this;
254 view->setMouseTracking(true);
255 layout->addWidget(view);
256 view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
257 view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
258 view->setStyleSheet("QGraphicsView { background: transparent; }");
259 view->setAlignment(Qt::AlignHCenter | Qt::AlignBottom);
260 connect(m_scene, &QGraphicsScene::sceneRectChanged, [view](const QRectF& rect){
261 view->fitInView(rect, Qt::KeepAspectRatio);
262 });
263
264 hlayout = new QHBoxLayout;
265 layout->addLayout(hlayout);
266 hlayout->addWidget(new QLabel(tr("Transactions"), this));
267 m_lbl_tx_count = new QLabel(this);
268 m_lbl_tx_count->setAlignment(Qt::AlignRight);
269 hlayout->addWidget(m_lbl_tx_count);
270
271 hlayout = new QHBoxLayout;
272 layout->addLayout(hlayout);
273 hlayout->addWidget(new QLabel(tr("Txn Fees"), this));
274 m_lbl_tx_fees = new QLabel(this);
275 m_lbl_tx_fees->setAlignment(Qt::AlignRight);
276 hlayout->addWidget(m_lbl_tx_fees);
277
278 connect(&m_timer, &QTimer::timeout, this, &GuiBlockView::updateScene);
279
280 m_validation_interface = new BlockViewValidationInterface(*this);
281 }
282
283 GuiBlockView::~GuiBlockView()
284 {
285 if (m_validation_interface) {
286 setClientModel(nullptr);
287 delete m_validation_interface;
288 m_validation_interface = nullptr;
289 }
290 }
291
292 void GuiBlockView::changeEvent(QEvent* e)
293 {
294 if (e->type() == QEvent::PaletteChange) {
295 updateThemeColors();
296 }
297 QDialog::changeEvent(e);
298 }
299
300 void GuiBlockView::setClientModel(ClientModel *model)
301 {
302 if (m_client_model) {
303 auto& validation_signals = m_client_model->node().context()->validation_signals;
304 if (validation_signals) {
305 validation_signals->UnregisterValidationInterface(m_validation_interface);
306 }
307 disconnect(m_client_model->getOptionsModel(), &OptionsModel::displayUnitChanged, this, &GuiBlockView::updateDisplayUnit);
308 }
309 m_client_model = model;
310 if (model) {
311 connect(model->getOptionsModel(), &OptionsModel::displayUnitChanged, this, &GuiBlockView::updateDisplayUnit);
312 updateDisplayUnit();
313
314 if (m_block_chooser->count() == 0) {
315 m_block_chooser->addItem(tr("This node's preferred block template"), -3);
316 m_block_chooser->addItem("", -2);
317 m_block_chooser->addItem(tr("Specific block"), -1);
318 m_block_chooser->setCurrentIndex(1);
319 }
320
321 auto chainman = getChainstateManager();
322 if (chainman) {
323 const auto pblockindex = WITH_LOCK(::cs_main, return chainman->ActiveChain().Tip());
324 updateBestBlock(pblockindex->nHeight);
325 }
326 auto& validation_signals = model->node().context()->validation_signals;
327 if (validation_signals) {
328 validation_signals->RegisterValidationInterface(m_validation_interface);
329 }
330 }
331 }
332
333 ChainstateManager* GuiBlockView::getChainstateManager() const
334 {
335 if (!m_client_model) return nullptr;
336 auto node_ctx = m_client_model->node().context();
337 if (!node_ctx) return nullptr;
338 auto& chainman = node_ctx->chainman;
339 if (!chainman) return nullptr;
340 return &(*chainman);
341 }
342
343 void GuiBlockView::clear()
344 {
345 LOCK(m_mutex);
346 m_block_fees = -1;
347 m_lbl_tx_count->setText("");
348 m_block.reset();
349 m_block_template.reset();
350 for (auto& [wtxid, elem] : m_elements) {
351 const auto gi = elem.gi;
352 m_scene->removeItem(gi);
353 delete gi;
354 }
355 m_elements.clear();
356 }
357
358 bool GuiBlockView::any_overlap(const Bubble& proposed, const std::vector<Bubble>& others)
359 {
360 for (const auto& other : others) {
361 const auto x_dist = std::abs(other.pos.x() - proposed.pos.x());
362 const auto y_dist = std::abs(other.pos.y() - proposed.pos.y());
363 const auto dist = std::sqrt((x_dist * x_dist) + (y_dist * y_dist));
364 if (dist < proposed.radius + other.radius + TX_PADDING_NEARBY) {
365 return true;
366 }
367 }
368 return false;
369 }
370
371 void GuiBlockView::setBlock(std::shared_ptr<const CBlock> block, const CAmount block_subsidy)
372 {
373 LOCK(m_mutex);
374 m_block_fees = [&] {
375 CAmount total{0};
376 Assert(!block->vtx.empty());
377 for (const auto& outp : block->vtx[0]->vout) {
378 total += outp.nValue;
379 }
380 return total - block_subsidy;
381 }();
382 m_block = block;
383 m_block_template.reset();
384 m_block_changed = true;
385 updateElements(/*instant=*/ true);
386 }
387
388 void GuiBlockView::setBlock(std::shared_ptr<const node::CBlockTemplate> blocktemplate)
389 {
390 LOCK(m_mutex);
391 const bool instant = (bool)m_block; // force instant if changing from real block to template
392 m_block_fees = -blocktemplate->vTxFees.front();
393 m_block.reset();
394 m_block_template = blocktemplate;
395 m_block_changed = true;
396 updateElements(/*instant=*/ instant);
397 }
398
399 void GuiBlockView::updateBlockFees(CAmount block_fees)
400 {
401 if (block_fees < 0) {
402 m_lbl_tx_fees->setText("");
403 return;
404 }
405 LimenkaUnit unit;
406 QFont font_for_money;
407 if (auto* options_model = m_client_model ? m_client_model->getOptionsModel() : nullptr; options_model) {
408 unit = options_model->getDisplayUnit();
409 font_for_money = options_model->getFontForMoney(unit);
410 } else {
411 unit = LimenkaUnit::BTC;
412 }
413 m_lbl_tx_fees->setFont(font_for_money);
414 m_lbl_tx_fees->setText(LimenkaUnits::formatWithUnit(unit, block_fees));
415 }
416
417 void GuiBlockView::updateDisplayUnit()
418 {
419 const auto block_fees = WITH_LOCK(m_mutex, return m_block_fees);
420 updateBlockFees(block_fees);
421 }
422
423 constexpr qreal offscreen{99};
424
425 void GuiBlockView::updateElements(bool instant)
426 {
427 if (!m_block_changed) return;
428
429 m_timer.stop();
430 m_block_changed = false;
431 auto pblocktemplate = m_block_template;
432 auto pblock = m_block;
433 auto& block = pblock ? *pblock : pblocktemplate->block;
434
435 instant |= m_elements.empty();
436 for (auto& el : m_elements) {
437 el.second.target_loc.setY(offscreen);
438 }
439 m_bubblegraph = std::make_unique<BubbleGraph>();
440 m_bubblegraph->txs_count = block.vtx.size() - 1;
441 auto& bubbles = m_bubblegraph->bubbles;
442 qreal limit_halfwidth{std::sqrt(::GetSerializeSize(TX_WITH_WITNESS(block))) * EXPECTED_WHITESPACE_PERCENT / 2};
443 for (size_t i = 1; i < block.vtx.size(); ++i) {
444 auto& tx = *block.vtx[i];
445 auto& el = m_elements[tx.GetWitnessHash()];
446 QPointF preferred_loc;
447 double diameter;
448 const auto tx_size = tx.GetTotalSize();
449 m_bubblegraph->txs_size += tx_size;
450 const bool fresh_bubble = !el.gi;
451 if (fresh_bubble) {
452 diameter = 2 * std::sqrt(tx_size / std::numbers::pi);
453 } else {
454 // preferred_loc = el.gi->pos();
455 diameter = el.gi->boundingRect().height();
456 }
457 Bubble proposed{ .tx = block.vtx[i], .pos = {}, .radius = diameter / 2, .el = &el, };
458 qreal x_extremity{proposed.radius};
459 if (bubbles.empty()) {
460 proposed.pos.setY(-proposed.radius);
461 }
462 for (auto bubble_it = bubbles.rbegin(); bubble_it != bubbles.rend(); ++bubble_it) {
463 const auto& centre = bubble_it->pos;
464 QPointF preferred_loc_rel(preferred_loc.x() - centre.x(), preferred_loc.y() - centre.y());
465 double preferred_angle;
466 if (preferred_loc_rel.isNull()) {
467 preferred_angle = std::numbers::pi / 2;
468 } else {
469 preferred_angle = std::atan2(preferred_loc.y() - centre.y(), preferred_loc.x() - centre.x());
470 }
471 const auto distance = bubble_it->radius + proposed.radius + TX_PADDING_NEXT;
472 double angle = preferred_angle;
473 bool found{false};
474 while (true) {
475 proposed.pos = QPointF(centre.x() + (distance * std::cos(angle)), centre.y() + (distance * std::sin(angle)));
476
477 x_extremity = std::abs(proposed.pos.x()) + proposed.radius;
478 if (proposed.pos.y() < -proposed.radius && x_extremity <= limit_halfwidth && !any_overlap(proposed, bubbles)) {
479 found = true;
480 break;
481 }
482
483 if (angle < preferred_angle) {
484 angle = preferred_angle + (preferred_angle - angle);
485 } else {
486 angle = preferred_angle - (angle - preferred_angle) - (std::numbers::pi / RADIAN_DIVISOR);
487 }
488 if (angle > preferred_angle + std::numbers::pi) {
489 break;
490 }
491 }
492 if (found) break;
493 }
494 m_bubblegraph->min_x = std::min(m_bubblegraph->min_x, proposed.pos.x() - proposed.radius);
495 m_bubblegraph->max_x = std::max(m_bubblegraph->max_x, proposed.pos.x() + proposed.radius);
496 m_bubblegraph->min_y = std::min(m_bubblegraph->min_y, proposed.pos.y() - proposed.radius);
497 bubbles.push_back(proposed);
498 el.target_loc = proposed.pos;
499 }
500 m_bubblegraph->instant = instant;
501 QMetaObject::invokeMethod(this, "updateSceneInit", Qt::QueuedConnection);
502 }
503
504 void GuiBlockView::updateSceneInit()
505 {
506 LOCK(m_mutex);
507 if (!m_bubblegraph) return;
508
509 m_lbl_tx_count->setText(tr("%1 (%2)").arg(m_bubblegraph->txs_count).arg(tr("%1 kB").arg(m_bubblegraph->txs_size / 1000.0, 0, 'f', 1)));
510 updateBlockFees(m_block_fees);
511
512 for (auto& bubble : m_bubblegraph->bubbles) {
513 auto& el = *bubble.el;
514 if (!el.gi) {
515 const auto diameter = bubble.radius * 2;
516 auto gi = m_scene->addEllipse(0, 0, diameter, diameter, QPen(palette().window(), TX_PADDING_NEARBY));
517 el.gi = gi;
518 gi->setData(0, QVariant::fromValue(std::move(bubble.tx)));
519 gi->setBrush(m_bubble_color);
520 gi->setPos(bubble.pos.x() - bubble.radius, m_bubblegraph->instant ? (bubble.pos.y() - bubble.radius) : offscreen);
521 }
522 }
523 for (auto it = m_elements.begin(); it != m_elements.end(); ) {
524 const auto& target_loc = it->second.target_loc;
525 const auto gi = it->second.gi;
526 bool delete_el{false};
527 if (target_loc.y() == offscreen || !gi /* never got a chance to exist */) {
528 delete_el = true;
529 // TODO: if confirmed, slide it off the bottom
530 // TODO: if conflicted, pop the bubble?
531 // TODO: if delayed, move off the top
532 } else {
533 if (gi->y() == offscreen) {
534 gi->setY(m_bubblegraph->min_y - gi->boundingRect().height());
535 }
536 }
537 if (delete_el) {
538 if (gi) {
539 m_scene->removeItem(gi);
540 delete gi;
541 }
542 it = m_elements.erase(it);
543 } else {
544 ++it;
545 }
546 }
547 m_scene->setSceneRect(m_bubblegraph->min_x, m_bubblegraph->min_y, m_bubblegraph->max_x - m_bubblegraph->min_x, -m_bubblegraph->min_y);
548 if (!m_bubblegraph->instant) {
549 m_frame_div = 4;
550 updateScene();
551 m_timer.start(100);
552 }
553 m_bubblegraph.reset();
554 }
555
556 void GuiBlockView::updateScene()
557 {
558 LOCK(m_mutex);
559 bool all_completed{true};
560 for (auto it = m_elements.begin(); it != m_elements.end(); ) {
561 const auto& target_loc = it->second.target_loc;
562 QGraphicsItem* gi = it->second.gi;
563 const auto radius = gi->boundingRect().width() / 2;
564 const QPointF current_loc(gi->pos().x() + radius, gi->pos().y() + radius);
565 bool delete_el{false};
566 if (target_loc != current_loc) {
567 // Get 25% closer each tick
568 QPointF new_loc(current_loc.x() + ((target_loc.x() - current_loc.x()) / m_frame_div),
569 current_loc.y() + ((target_loc.y() - current_loc.y()) / m_frame_div));
570 if (std::abs(new_loc.x() - target_loc.x()) < TX_PADDING_NEXT) {
571 new_loc.setX(target_loc.x());
572 }
573 if (std::abs(new_loc.y() - target_loc.y()) < TX_PADDING_NEXT) {
574 new_loc.setY(target_loc.y());
575 }
576 gi->setPos(new_loc.x() - radius, new_loc.y() - radius);
577 if (new_loc == target_loc) {
578 if (target_loc.y() + radius < m_scene->sceneRect().y() || target_loc.y() - radius > 0) {
579 delete_el = true;
580 }
581 } else {
582 all_completed = false;
583 }
584 }
585 if (delete_el) {
586 m_scene->removeItem(gi);
587 delete gi;
588 it = m_elements.erase(it);
589 } else {
590 ++it;
591 }
592 }
593 --m_frame_div;
594 if (all_completed) {
595 m_timer.stop();
596 }
597 }
598
599 void GuiBlockView::updateThemeColors()
600 {
601 // Store old color to check if it actually changes
602 const QColor old_color = m_bubble_color;
603
604 // Detect dark mode for color palette selection
605 const bool dark_mode = GUIUtil::isDarkMode(palette().color(backgroundRole()));
606 m_bubble_color = dark_mode ? QColor(137, 170, 255) : QColor(2, 61, 204);
607
608 // Only update existing bubbles if color actually changed
609 if (old_color != m_bubble_color) {
610 LOCK(m_mutex);
611 for (auto& el : m_elements) {
612 if (el.second.gi) {
613 if (auto* ellipse = dynamic_cast<QGraphicsEllipseItem*>(el.second.gi)) {
614 ellipse->setBrush(m_bubble_color);
615 }
616 }
617 }
618 }
619 }
620