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