From 222b89f7e4d8c8c8af0e10854026335246333366 Mon Sep 17 00:00:00 2001 From: uncor3 Date: Wed, 25 Feb 2026 07:57:00 +0000 Subject: [PATCH] feat(balloon): refactor balloon handling and introduce BalloonProcess class --- src/iDescriptor.h | 36 ++- src/qballoontip.cpp | 209 ++++--------- src/qballoontip.h | 12 +- src/statusballoon.cpp | 683 ++++++++++++++++-------------------------- src/statusballoon.h | 34 ++- 5 files changed, 376 insertions(+), 598 deletions(-) diff --git a/src/iDescriptor.h b/src/iDescriptor.h index c8dd97b..3a4c057 100644 --- a/src/iDescriptor.h +++ b/src/iDescriptor.h @@ -647,11 +647,13 @@ struct ExportItem { QString sourcePathOnDevice; QString suggestedFileName; int itemIndex = -1; + std::string d_udid; ExportItem() = default; - ExportItem(const QString &sourcePath, const QString &fileName, int index) + ExportItem(const QString &sourcePath, const QString &fileName, + std::string d_udid, int index) : sourcePathOnDevice(sourcePath), suggestedFileName(fileName), - itemIndex(index) + d_udid(d_udid), itemIndex(index) { } }; @@ -676,10 +678,36 @@ struct ExportJobSummary { struct ExportJob { QUuid jobId; - iDescriptorDevice *device = nullptr; QList items; QString destinationPath; std::optional altAfc; std::atomic cancelRequested{false}; QUuid statusBalloonProcessId; -}; \ No newline at end of file + // device udid + std::string d_udid; +}; + +inline QString formatFileSize(qint64 bytes) +{ + const qint64 KB = 1024; + const qint64 MB = KB * 1024; + const qint64 GB = MB * 1024; + + if (bytes >= GB) { + return QString("%1 GB").arg( + QString::number(bytes / double(GB), 'f', 2)); + } else if (bytes >= MB) { + return QString("%1 MB").arg( + QString::number(bytes / double(MB), 'f', 1)); + } else if (bytes >= KB) { + return QString("%1 KB").arg( + QString::number(bytes / double(KB), 'f', 0)); + } else { + return QString("%1 B").arg(bytes); + } +} + +inline QString formatTransferRate(qint64 bytesPerSecond) +{ + return formatFileSize(bytesPerSecond) + "/s"; +} \ No newline at end of file diff --git a/src/qballoontip.cpp b/src/qballoontip.cpp index 2817a0e..e42f569 100644 --- a/src/qballoontip.cpp +++ b/src/qballoontip.cpp @@ -15,49 +15,40 @@ #include #include -static QBalloonTip *theSolitaryBalloonTip = nullptr; - -void QBalloonTip::showBalloon(const QIcon &icon, const QString &title, - const QString &message, QWidget *widget, - const QPoint &pos, int timeout, bool showArrow) +void QBalloonTip::toggleBaloon(const QPoint &pos, int timeout, + bool forceVisible) { - hideBalloon(); - if (message.isEmpty() && title.isEmpty()) + if (m_visible && !forceVisible) { + hideBalloon(); return; + } - theSolitaryBalloonTip = new QBalloonTip(icon, title, message, widget); if (timeout < 0) timeout = 10000; // 10 s default - theSolitaryBalloonTip->balloon(pos, timeout, showArrow); + balloon(pos, timeout); } void QBalloonTip::hideBalloon() { - if (!theSolitaryBalloonTip) - return; - theSolitaryBalloonTip->hide(); - // delete theSolitaryBalloonTip; - theSolitaryBalloonTip = nullptr; + m_visible = false; + hide(); } void QBalloonTip::updateBalloonPosition(const QPoint &pos) { - if (!theSolitaryBalloonTip) - return; - theSolitaryBalloonTip->hide(); - theSolitaryBalloonTip->balloon(pos, 0, theSolitaryBalloonTip->showArrow); + hideBalloon(); + balloon(pos, 0); } -bool QBalloonTip::isBalloonVisible() { return theSolitaryBalloonTip; } +bool QBalloonTip::isBalloonVisible() { return m_visible; } -QBalloonTip::QBalloonTip(const QIcon &icon, const QString &title, - const QString &message, QWidget *widget) +QBalloonTip::QBalloonTip(QWidget *widget) : QWidget(widget ? widget->window() : QApplication::activeWindow(), Qt::ToolTip), - widget(widget), showArrow(true) + widget(widget) { // setAttribute(Qt::WA_DeleteOnClose); - setAttribute(Qt::WA_TranslucentBackground); + // setAttribute(Qt::WA_TranslucentBackground); if (widget) { connect(widget, &QWidget::destroyed, this, &QBalloonTip::close); } else if (QApplication::activeWindow()) { @@ -119,146 +110,64 @@ QBalloonTip::QBalloonTip(const QIcon &icon, const QString &title, // setLayout(layout); } -QBalloonTip::~QBalloonTip() { theSolitaryBalloonTip = nullptr; } +QBalloonTip::~QBalloonTip() {} void QBalloonTip::paintEvent(QPaintEvent *ev) { - QPainter painter(this); - painter.drawPixmap(rect(), pixmap); + // QPainter painter(this); + // painter.drawPixmap(rect(), pixmap); QWidget::paintEvent(ev); } void QBalloonTip::resizeEvent(QResizeEvent *ev) { QWidget::resizeEvent(ev); } -void QBalloonTip::balloon(const QPoint &pos, int msecs, bool showArrow) +void QBalloonTip::balloon(const QPoint &pos, int msecs) { - // this->showArrow = showArrow; - // QScreen *screen = QGuiApplication::screenAt(pos); - // if (!screen) - // screen = QGuiApplication::primaryScreen(); - // QRect screenRect = screen->geometry(); - // QSize sh = sizeHint(); - // const int border = 1; - // const int ah = 18, aw = 18, rc = 7; - // bool arrowAtTop = (pos.y() + sh.height() + ah < screenRect.height()); - - // setContentsMargins(border + 3, border + (arrowAtTop ? ah : 0) + 2, - // border + 3, border + (arrowAtTop ? 0 : ah) + 2); - // updateGeometry(); - // sh = sizeHint(); - - // // Center the balloon relative to the pos point (button center) - // int balloonX = pos.x() - sh.width() / 2; - - // // Calculate arrow offset from left edge of balloon to center it - // int ao = sh.width() / 2 - aw / 2; // Center the arrow on the balloon - - // int ml, mr, mt, mb; - // QSize sz = sizeHint(); - // if (!arrowAtTop) { - // ml = mt = 0; - // mr = sz.width() - 1; - // mb = sz.height() - ah - 1; - // } else { - // ml = 0; - // mt = ah; - // mr = sz.width() - 1; - // mb = sz.height() - 1; - // } - - // QPainterPath path; - // path.moveTo(ml + rc, mt); - // if (arrowAtTop) { - // if (showArrow) { - // path.lineTo(ml + ao, mt); - // path.lineTo(ml + ao + aw / 2, mt - ah); - // path.lineTo(ml + ao + aw, mt); - // } - // move(qBound(screenRect.left() + 2, balloonX, - // screenRect.right() - sh.width() - 2), - // pos.y()); - // } - // path.lineTo(mr - rc, mt); - // path.arcTo(QRect(mr - rc * 2, mt, rc * 2, rc * 2), 90, -90); - // path.lineTo(mr, mb - rc); - // path.arcTo(QRect(mr - rc * 2, mb - rc * 2, rc * 2, rc * 2), 0, -90); - // if (!arrowAtTop) { - // if (showArrow) { - // path.lineTo(mr - ao - aw, mb); - // path.lineTo(mr - ao - aw / 2, mb + ah); - // path.lineTo(mr - ao, mb); - // } - // move(qBound(screenRect.left() + 2, balloonX, - // screenRect.right() - sh.width() - 2), - // pos.y() - sh.height()); - // } - // path.lineTo(ml + rc, mb); - // path.arcTo(QRect(ml, mb - rc * 2, rc * 2, rc * 2), -90, -90); - // path.lineTo(ml, mt + rc); - // path.arcTo(QRect(ml, mt, rc * 2, rc * 2), 180, -90); - - // // Set the mask - // QBitmap bitmap = QBitmap(sizeHint()); - // bitmap.fill(Qt::color0); - // QPainter painter1(&bitmap); - // painter1.setPen(QPen(Qt::color1, border)); - // painter1.setBrush(QBrush(Qt::color1)); - // painter1.drawPath(path); - // setMask(bitmap); - - // // Draw the border with background color - // pixmap = QPixmap(sz); - // pixmap.fill(Qt::transparent); - // QPainter painter2(&pixmap); - // painter2.setRenderHint(QPainter::Antialiasing); - // bool isDark = isDarkMode(); - // QColor lightColor = qApp->palette().color(QPalette::Light); - // QColor darkColor = qApp->palette().color(QPalette::Dark); - // QColor bgColor = isDark ? lightColor : darkColor; - // painter2.setPen(QPen(bgColor.darker(160), border)); - // painter2.setBrush(bgColor); - // painter2.drawPath(path); - - if (msecs > 0) - timer.start(msecs, this); - - // Install event filter to detect clicks outside + m_visible = true; qApp->installEventFilter(this); - // // Set initial scale and opacity for animation - // setWindowOpacity(0.0); + QScreen *screen = QGuiApplication::screenAt(pos); + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + QRect scr = screen->availableGeometry(); + const int border = 1; + const int ah = 18, ao = 18, aw = 18, rc = 7; + // bool arrowAtTop = (pos.y() + sh.height() + ah < scr.height()); + // bool arrowAtLeft = (pos.x() + sh.width() - ao < scr.width()); + // setContentsMargins(border + 3, border + (arrowAtTop ? ah : 0) + 2, + // border + 3, border + (arrowAtTop ? 0 : ah) + 2); + updateGeometry(); + QSize sz = sizeHint(); + QRect screenRect = screen->availableGeometry(); - // Store the transform origin point (center of the widget) - QPoint center = rect().center(); - setProperty("transformOriginPoint", center); + // Calculate the total required size for the balloon widget, including + // potential arrow space. Assuming the arrow takes up 'ah' height at either + // the top or bottom of the widget. + QSize sh_total = QSize(sz.width(), sz.height() + ah); + // Determine the desired X position: center the balloon horizontally on + // pos.x 'pos' is the global bottom-center of your button. + int targetX = pos.x() - sh_total.width() / 2; + // Clamp X position to screen bounds + targetX = qBound(screenRect.left(), targetX, + screenRect.right() - sh_total.width()); + + // Determine the desired Y position: Place the bottom of the balloon at + // pos.y() (button's bottom) This makes the balloon appear ABOVE the button. + int targetY = pos.y() - sh_total.height(); + // Clamp Y position to screen bounds + targetY = qBound(screenRect.top(), targetY, + screenRect.bottom() - sh_total.height()); + + // Apply the calculated position + move(targetX, targetY); + + // if (msecs > 0) + // timer = startTimer(msecs); show(); - - // Create scale and opacity animations - QPropertyAnimation *scaleAnim = new QPropertyAnimation(this, "geometry"); - scaleAnim->setDuration(200); - scaleAnim->setEasingCurve(QEasingCurve::OutBack); - - // Calculate scaled geometry (start from 80% size) - QRect finalGeometry = geometry(); - QRect startGeometry = finalGeometry; - int widthDiff = finalGeometry.width() * 0.2; - int heightDiff = finalGeometry.height() * 0.2; - startGeometry.adjust(widthDiff / 2, heightDiff / 2, -widthDiff / 2, - -heightDiff / 2); - - scaleAnim->setStartValue(startGeometry); - scaleAnim->setEndValue(finalGeometry); - - // QPropertyAnimation *opacityAnim = - // new QPropertyAnimation(this, "windowOpacity"); - // opacityAnim->setDuration(200); - // opacityAnim->setStartValue(0.0); - // opacityAnim->setEndValue(1.0); - // opacityAnim->setEasingCurve(QEasingCurve::OutCubic); - - // scaleAnim->start(QAbstractAnimation::DeleteWhenStopped); - // opacityAnim->start(QAbstractAnimation::DeleteWhenStopped); + raise(); + activateWindow(); } void QBalloonTip::mousePressEvent(QMouseEvent *e) @@ -285,12 +194,14 @@ bool QBalloonTip::eventFilter(QObject *obj, QEvent *event) QMouseEvent *mouseEvent = static_cast(event); // Check if click is outside the balloon if (!geometry().contains(mouseEvent->globalPos())) { + m_visible = false; close(); return false; } } else if (event->type() == QEvent::WindowDeactivate) { // Close when window loses focus if (obj == this) { + m_visible = false; close(); return false; } diff --git a/src/qballoontip.h b/src/qballoontip.h index ea932e7..ae5a721 100644 --- a/src/qballoontip.h +++ b/src/qballoontip.h @@ -10,15 +10,12 @@ class QBalloonTip : public QWidget { Q_OBJECT public: - explicit QBalloonTip(const QIcon &icon, const QString &title, - const QString &msg, QWidget *widget); + explicit QBalloonTip(QWidget *widget); void hideBalloon(); bool isBalloonVisible(); void updateBalloonPosition(const QPoint &pos); - void showBalloon(const QIcon &icon, const QString &title, - const QString &msg, QWidget *widget, const QPoint &pos, - int timeout, bool showArrow = true); - void balloon(const QPoint &, int, bool); + void toggleBaloon(const QPoint &pos, int timeout, bool forceVisible); + void balloon(const QPoint &, int msecs); signals: void messageClicked(); @@ -34,8 +31,7 @@ protected: private: QWidget *widget; - QPixmap pixmap; QBasicTimer timer; - bool showArrow; + bool m_visible = false; }; #endif // QBALLOONTIP_H \ No newline at end of file diff --git a/src/statusballoon.cpp b/src/statusballoon.cpp index bb1f0b1..a090d1b 100644 --- a/src/statusballoon.cpp +++ b/src/statusballoon.cpp @@ -30,290 +30,18 @@ #include "platform/windows/win_common.h" #endif -Process::Process(QWidget *parent) : QWidget(parent) {} - -StatusBalloon *StatusBalloon::sharedInstance() +BalloonProcess::BalloonProcess(ProcessItem *item, QWidget *parent) + : QWidget(parent), m_item(item) { - static StatusBalloon instance; - return &instance; -} - -StatusBalloon::StatusBalloon(QWidget *parent) - : QBalloonTip(QIcon(), "", "", parent) -{ - setMinimumHeight(300); - setMinimumWidth(300); -#ifdef WIN32 - // FIXME: doesnt work the second time we call it - enableAcrylic((HWND)winId()); -#endif - // Create main layout - m_mainLayout = new QVBoxLayout(); - m_mainLayout->setSpacing(8); - m_mainLayout->setContentsMargins(12, 12, 12, 12); - - // Header label - m_headerLabel = new QLabel("Processes"); - QFont headerFont = m_headerLabel->font(); - headerFont.setPointSize(headerFont.pointSize() + 2); - headerFont.setBold(true); - m_headerLabel->setFont(headerFont); - m_mainLayout->addWidget(m_headerLabel); - - // Container for processes - m_processesContainer = new QWidget(); - m_processesLayout = new QVBoxLayout(m_processesContainer); - m_processesLayout->setSpacing(12); - m_processesLayout->setContentsMargins(0, 0, 0, 0); - m_mainLayout->addWidget(m_processesContainer); - - setLayout(m_mainLayout); - connect(m_button, &ZIconWidget::clicked, this, &StatusBalloon::showBalloon); - connectExportThreadSignals(); -} - -void StatusBalloon::connectExportThreadSignals() -{ - ExportManager *exportManager = ExportManager::sharedInstance(); - - connect(exportManager->m_exportThread, &ExportManagerThread::exportFinished, - this, &StatusBalloon::onExportFinished); - - connect(exportManager->m_exportThread, &ExportManagerThread::itemExported, - this, &StatusBalloon::onItemExported); - - connect(exportManager->m_exportThread, - &ExportManagerThread::fileTransferProgress, this, - &StatusBalloon::onFileTransferProgress); - // QTimer::singleShot(0, this, [this]() { - // // test - // startExportProcess("Test Export Process", 10, - // "/path/to/destination"); - // }); -} - -void StatusBalloon::onFileTransferProgress(const QUuid &processId, - int currentItem, - const QString ¤tFile, - qint64 bytesTransferred, - qint64 totalBytes) -{ - qDebug() << "StatusBalloon::updateProcessProgress entry:" << processId - << currentItem << currentFile << bytesTransferred << totalBytes; - QMutexLocker locker(&m_processesMutex); - - if (!m_processes.contains(processId)) { - qDebug() << "StatusBalloon::updateProcessProgress: unknown processId" - << processId; - return; - } - - ProcessItem *item = m_processes[processId]; - item->completedItems = currentItem; - item->currentFile = currentFile; - item->transferredBytes = bytesTransferred; - item->totalBytes = totalBytes; - - if (!item->processWidget) - qDebug() - << "StatusBalloon::updateProcessProgress: no widget for processId" - << processId; - - // Update status label - QString statusText; - if (item->status == ProcessStatus::Running) { - if (!item->currentFile.isEmpty()) { - statusText = item->currentFile; - } else { - statusText = "Processing..."; - } - } else if (item->status == ProcessStatus::Completed) { - statusText = "Completed successfully"; - } else if (item->status == ProcessStatus::Failed) { - statusText = "Failed"; - } else if (item->status == ProcessStatus::Cancelled) { - statusText = "Cancelled"; - } - item->statusLabel->setText(statusText); - - // Update progress bar - // progess should be based on exported bytes vs total bytes of the current - // file - if (item->totalItems > 0) { - int progress = (item->transferredBytes * 100) / item->totalBytes; - item->progressBar->setValue(progress); - } - - // Update stats - QString statsText = QString("%1 of %2 items") - .arg(item->completedItems) - .arg(item->totalItems); - if (item->failedItems > 0) { - statsText += QString(" • %1 failed").arg(item->failedItems); - } - - if (item->status == ProcessStatus::Running && item->transferredBytes > 0) { - // Calculate transfer rate - QDateTime now = QDateTime::currentDateTime(); - qint64 elapsed = m_lastUpdateTime[item->processId].msecsTo(now); - if (elapsed > 0) { - qint64 bytesDiff = item->transferredBytes - - m_lastBytesTransferred[item->processId]; - qint64 bytesPerSecond = (bytesDiff * 1000) / elapsed; - if (bytesPerSecond > 0) { - statsText += " • " + formatTransferRate(bytesPerSecond); - } - m_lastBytesTransferred[item->processId] = item->transferredBytes; - m_lastUpdateTime[item->processId] = now; - } - } - - item->statsLabel->setText(statsText); - - // Update buttons - if (item->status == ProcessStatus::Running) { - item->cancelButton->setVisible(true); - item->actionButton->setVisible(false); - } else { - item->cancelButton->setVisible(false); - if (item->type == ProcessType::Export && - item->status == ProcessStatus::Completed) { - item->actionButton->setVisible(true); - } - } -} - -// todo fix these -// StatusBalloon::onItemExported entry: -// QUuid("{9bd97848-cb52-4ef8-93c1-a1d1c285e6a3}") Success: true -// StatusBalloon::onItemExported: unknown processId -// QUuid("{9bd97848-cb52-4ef8-93c1-a1d1c285e6a3}") -// StatusBalloon::onExportFinished entry: -// QUuid("{9bd97848-cb52-4ef8-93c1-a1d1c285e6a3}") WasCancelled: false -// StatusBalloon::onExportFinished: unknown processId -// QUuid("{9bd97848-cb52-4ef8-93c1-a1d1c285e6a3}") - -void StatusBalloon::onExportFinished(const QUuid &processId, - const ExportJobSummary &summary) -{ - qDebug() << "StatusBalloon::onExportFinished entry:" << processId - << "WasCancelled:" << summary.wasCancelled; - QMutexLocker locker(&m_processesMutex); - if (!m_processes.contains(processId)) { - qDebug() << "StatusBalloon::onExportFinished: unknown processId" - << processId; - return; - } - - // todo: handle failed ? - ProcessItem *item = m_processes[processId]; - if (summary.wasCancelled) { - item->status = ProcessStatus::Cancelled; - } else { - item->status = ProcessStatus::Completed; - } - item->endTime = QDateTime::currentDateTime(); - - updateUI(); -} - -void StatusBalloon::onItemExported(const QUuid &processId, - const ExportResult &result) -{ - qDebug() << "StatusBalloon::onItemExported entry:" << processId - << "Success:" << result.success; - QMutexLocker locker(&m_processesMutex); - - if (!m_processes.contains(processId)) { - qDebug() << "StatusBalloon::onItemExported: unknown processId" - << processId; - return; - } - - ProcessItem *item = m_processes[processId]; - if (result.success) { - item->completedItems += 1; - } else { - item->failedItems += 1; - } - - updateUI(); -} - -QUuid StatusBalloon::startExportProcess(const QString &title, int totalItems, - const QString &destinationPath) -{ - qDebug() << "StatusBalloon::startExportProcess entry:" << title - << totalItems << destinationPath; - - // allocate item first so it can be used after unlocking - auto *item = new ProcessItem(); - item->processId = QUuid::createUuid(); - item->type = ProcessType::Export; - item->status = ProcessStatus::Running; - item->title = title; - item->totalItems = totalItems; - item->completedItems = 0; - item->failedItems = 0; - item->totalBytes = 0; - item->transferredBytes = 0; - item->startTime = QDateTime::currentDateTime(); - item->destinationPath = destinationPath; - - { // scope the lock only for shared-state mutation - QMutexLocker locker(&m_processesMutex); - m_processes[item->processId] = item; - m_currentProcessId = item->processId; - m_lastBytesTransferred[item->processId] = 0; - m_lastUpdateTime[item->processId] = QDateTime::currentDateTime(); - } // mutex released here - - // UI work must run without holding m_processesMutex to avoid re-locking - // deadlock - createProcessWidget(item); - updateUI(); - - return item->processId; -} - -QUuid StatusBalloon::startUploadProcess(const QString &title, int totalItems) -{ - // allocate item first - auto *item = new ProcessItem(); - item->processId = QUuid::createUuid(); - item->type = ProcessType::Upload; - item->status = ProcessStatus::Running; - item->title = title; - item->totalItems = totalItems; - item->completedItems = 0; - item->failedItems = 0; - item->totalBytes = 0; - item->transferredBytes = 0; - item->startTime = QDateTime::currentDateTime(); - - { // scope the lock only for shared-state mutation - QMutexLocker locker(&m_processesMutex); - m_processes[item->processId] = item; - m_currentProcessId = item->processId; - m_lastBytesTransferred[item->processId] = 0; - m_lastUpdateTime[item->processId] = QDateTime::currentDateTime(); - } // mutex released here - - createProcessWidget(item); - updateUI(); - - return item->processId; -} - -void StatusBalloon::createProcessWidget(ProcessItem *item) -{ - item->processWidget = new QWidget(); - auto *layout = new QVBoxLayout(item->processWidget); + auto *layout = new QVBoxLayout(this); layout->setSpacing(6); layout->setContentsMargins(0, 0, 0, 0); + m_lastBytesTransferred = 0; + m_lastUpdateTime = QDateTime::currentDateTime(); + // Title - item->titleLabel = new QLabel(item->title); + item->titleLabel = new QLabel(m_item->title); QFont titleFont = item->titleLabel->font(); titleFont.setBold(true); item->titleLabel->setFont(titleFont); @@ -347,7 +75,8 @@ void StatusBalloon::createProcessWidget(ProcessItem *item) item->actionButton->setVisible(false); if (item->type == ProcessType::Export) { item->actionButton->setText("Open Folder"); - connect(item->actionButton, &QPushButton::clicked, this, + connect(item->actionButton, &QPushButton::clicked, + StatusBalloon::sharedInstance(), &StatusBalloon::onOpenFolderClicked); } buttonsLayout->addWidget(item->actionButton); @@ -356,85 +85,263 @@ void StatusBalloon::createProcessWidget(ProcessItem *item) // Cancel button item->cancelButton = new QPushButton("Cancel"); - connect(item->cancelButton, &QPushButton::clicked, this, - &StatusBalloon::onCancelClicked); + connect(item->cancelButton, &QPushButton::clicked, + StatusBalloon::sharedInstance(), &StatusBalloon::onCancelClicked); buttonsLayout->addWidget(item->cancelButton); layout->addLayout(buttonsLayout); - - m_processesLayout->addWidget(item->processWidget); } -void StatusBalloon::markProcessCompleted(const QUuid &processId) +void BalloonProcess::setProgress(int progress) { - QMutexLocker locker(&m_processesMutex); + m_item->progressBar->setValue(progress); +} - if (!m_processes.contains(processId)) { - return; +void BalloonProcess::updateStats() +{ + QString statsText = QString("%1 of %2 items") + .arg(m_item->completedItems) + .arg(m_item->totalItems); + if (m_item->failedItems > 0) { + statsText += QString(" • %1 failed").arg(m_item->failedItems); } - ProcessItem *item = m_processes[processId]; - item->status = ProcessStatus::Completed; - item->endTime = QDateTime::currentDateTime(); + if (m_item->status == ProcessStatus::Running && + m_item->transferredBytes > 0) { + // Calculate transfer rate + QDateTime now = QDateTime::currentDateTime(); + qint64 elapsed = m_lastUpdateTime.msecsTo(now); + if (elapsed > 0) { + qint64 bytesDiff = + m_item->transferredBytes - m_lastBytesTransferred; + qint64 bytesPerSecond = (bytesDiff * 1000) / elapsed; + if (bytesPerSecond > 0) { + statsText += " • " + formatTransferRate(bytesPerSecond); + } + m_lastBytesTransferred = m_item->transferredBytes; + m_lastUpdateTime = now; + } + } - updateUI(); + m_item->statsLabel->setText(statsText); +} - // Check if all processes are done - bool allDone = true; - for (auto *proc : m_processes) { - if (proc->status == ProcessStatus::Running) { - allDone = false; - break; +void BalloonProcess::updateButtons() +{ + // Update buttons + if (m_item->status == ProcessStatus::Running) { + m_item->cancelButton->setVisible(true); + m_item->actionButton->setVisible(false); + } else { + m_item->cancelButton->setVisible(false); + if (m_item->type == ProcessType::Export && + m_item->status == ProcessStatus::Completed) { + m_item->actionButton->setVisible(true); } } } -void StatusBalloon::markProcessFailed(const QUuid &processId, - const QString &error) +StatusBalloon *StatusBalloon::sharedInstance() { - QMutexLocker locker(&m_processesMutex); + static StatusBalloon instance; + return &instance; +} + +StatusBalloon::StatusBalloon(QWidget *parent) : QBalloonTip(parent) +{ + setMinimumHeight(300); + setMinimumWidth(300); +#ifdef WIN32 + // FIXME: doesnt work the second time we call it + enableAcrylic((HWND)winId()); +#endif + // Create main layout + m_mainLayout = new QVBoxLayout(); + m_mainLayout->setSpacing(8); + m_mainLayout->setContentsMargins(0, 0, 0, 0); + + // Header label + m_headerLabel = new QLabel("Processes"); + QFont headerFont = m_headerLabel->font(); + headerFont.setPointSize(headerFont.pointSize() + 2); + headerFont.setBold(true); + m_headerLabel->setFont(headerFont); + m_mainLayout->addWidget(m_headerLabel); + + // Container for processes + m_processesContainer = new QWidget(); + m_processesLayout = new QVBoxLayout(m_processesContainer); + + QScrollArea *scrollArea = new QScrollArea(); + scrollArea->setWidget(m_processesContainer); + scrollArea->setWidgetResizable(true); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scrollArea->setStyleSheet( + "QScrollArea { background: transparent; border: none; }"); + scrollArea->viewport()->setStyleSheet("background: transparent;"); + + m_processesLayout->setSpacing(12); + m_processesLayout->setContentsMargins(5, 5, 5, 5); + m_mainLayout->addWidget(scrollArea); + + setLayout(m_mainLayout); + connect(m_button, &ZIconWidget::clicked, this, &StatusBalloon::handleShow); + connectExportThreadSignals(); +} + +void StatusBalloon::connectExportThreadSignals() +{ + ExportManager *exportManager = ExportManager::sharedInstance(); + + connect(exportManager->m_exportThread, &ExportManagerThread::exportFinished, + this, &StatusBalloon::onExportFinished); + + connect(exportManager->m_exportThread, &ExportManagerThread::itemExported, + this, &StatusBalloon::onItemExported); + + connect(exportManager->m_exportThread, + &ExportManagerThread::fileTransferProgress, this, + &StatusBalloon::onFileTransferProgress); + QTimer::singleShot(3000, this, [this]() { + // test + startExportProcess("Test Export Process", 10, "/path/to/destination"); + }); +} + +void StatusBalloon::onFileTransferProgress(const QUuid &processId, + int currentItem, + const QString ¤tFile, + qint64 bytesTransferred, + qint64 totalBytes) +{ + qDebug() << "StatusBalloon::updateProcessProgress"; + // QMutexLocker locker(&m_processesMutex); if (!m_processes.contains(processId)) { + qDebug() << "StatusBalloon::updateProcessProgress: unknown processId" + << processId; return; } ProcessItem *item = m_processes[processId]; - item->status = ProcessStatus::Failed; - item->endTime = QDateTime::currentDateTime(); + item->completedItems = currentItem; + item->currentFile = currentFile; + item->transferredBytes = bytesTransferred; + item->totalBytes = totalBytes; - updateUI(); + if (!item->processWidget) { + qDebug() + << "StatusBalloon::updateProcessProgress: no widget for processId" + << processId; + return; + } + + handleJobUpdate(item); } -void StatusBalloon::markProcessCancelled(const QUuid &processId) +void StatusBalloon::onExportFinished(const QUuid &processId, + const ExportJobSummary &summary) { + qDebug() << "StatusBalloon::onExportFinished entry:" << processId + << "WasCancelled:" << summary.wasCancelled; + QMutexLocker locker(&m_processesMutex); + if (!m_processes.contains(processId)) { + qDebug() << "StatusBalloon::onExportFinished: unknown processId" + << processId; + return; + } + + // todo: handle failed ? + ProcessItem *item = m_processes[processId]; + if (summary.wasCancelled) { + item->status = ProcessStatus::Cancelled; + } else { + item->status = ProcessStatus::Completed; + } + item->endTime = QDateTime::currentDateTime(); + + updateHeader(); +} + +void StatusBalloon::onItemExported(const QUuid &processId, + const ExportResult &result) +{ + qDebug() << "StatusBalloon::onItemExported entry:" << processId + << "Success:" << result.success; QMutexLocker locker(&m_processesMutex); if (!m_processes.contains(processId)) { + qDebug() << "StatusBalloon::onItemExported: unknown processId" + << processId; return; } ProcessItem *item = m_processes[processId]; - item->status = ProcessStatus::Cancelled; - item->endTime = QDateTime::currentDateTime(); - - updateUI(); -} - -void StatusBalloon::incrementFailedItems(const QUuid &processId) -{ - QMutexLocker locker(&m_processesMutex); - - if (!m_processes.contains(processId)) { - return; + if (result.success) { + item->completedItems += 1; + } else { + item->failedItems += 1; } - m_processes[processId]->failedItems++; - updateUI(); + if (item->completedItems + item->failedItems == item->totalItems) { + // meaning all items are processed, but we don't know if the overall + // status is + if (item->failedItems > 0) { + item->status = ProcessStatus::Failed; + } else { + item->status = ProcessStatus::Completed; + } + } + handleJobUpdate(item); + updateHeader(); } -void StatusBalloon::updateUI() +QUuid StatusBalloon::startExportProcess(const QString &title, int totalItems, + const QString &destinationPath) { - QMutexLocker locker(&m_processesMutex); + qDebug() << "StatusBalloon::startExportProcess entry:" << title + << totalItems << destinationPath; + + handleShow(); // ensure balloon is visible when process starts + + auto *item = new ProcessItem(); + item->processId = QUuid::createUuid(); + item->type = ProcessType::Export; + item->status = ProcessStatus::Running; + item->title = title; + item->totalItems = totalItems; + item->completedItems = 0; + item->failedItems = 0; + item->totalBytes = 0; + item->transferredBytes = 0; + item->startTime = QDateTime::currentDateTime(); + item->destinationPath = destinationPath; + + { // scope the lock only for shared-state mutation + QMutexLocker locker(&m_processesMutex); + m_processes[item->processId] = item; + m_currentProcessId = item->processId; + } // mutex released here + + // UI work must run without holding m_processesMutex to avoid re-locking + // deadlock + createProcessWidget(item); + updateHeader(); + + return item->processId; +} + +void StatusBalloon::createProcessWidget(ProcessItem *item) +{ + BalloonProcess *processWidget = new BalloonProcess(item); + item->processWidget = processWidget; + m_processesLayout->addWidget(item->processWidget); +} + +void StatusBalloon::updateHeader() +{ + // QMutexLocker locker(&m_processesMutex); // Update header int running = 0, completed = 0, failed = 0; @@ -455,86 +362,14 @@ void StatusBalloon::updateUI() } } m_headerLabel->setText(headerText); - - // Update each process widget - for (auto *item : m_processes) { - if (!item->processWidget) - continue; - - // Update status label - QString statusText; - if (item->status == ProcessStatus::Running) { - if (!item->currentFile.isEmpty()) { - statusText = item->currentFile; - } else { - statusText = "Processing..."; - } - } else if (item->status == ProcessStatus::Completed) { - statusText = "Completed successfully"; - } else if (item->status == ProcessStatus::Failed) { - statusText = "Failed"; - } else if (item->status == ProcessStatus::Cancelled) { - statusText = "Cancelled"; - } - item->statusLabel->setText(statusText); - - // Update progress bar - if (item->totalItems > 0) { - int progress = (item->completedItems * 100) / item->totalItems; - item->progressBar->setValue(progress); - } - - // Update stats - QString statsText = QString("%1 of %2 items") - .arg(item->completedItems) - .arg(item->totalItems); - if (item->failedItems > 0) { - statsText += QString(" • %1 failed").arg(item->failedItems); - } - - if (item->status == ProcessStatus::Running && - item->transferredBytes > 0) { - // Calculate transfer rate - QDateTime now = QDateTime::currentDateTime(); - qint64 elapsed = m_lastUpdateTime[item->processId].msecsTo(now); - if (elapsed > 0) { - qint64 bytesDiff = item->transferredBytes - - m_lastBytesTransferred[item->processId]; - qint64 bytesPerSecond = (bytesDiff * 1000) / elapsed; - if (bytesPerSecond > 0) { - statsText += " • " + formatTransferRate(bytesPerSecond); - } - m_lastBytesTransferred[item->processId] = - item->transferredBytes; - m_lastUpdateTime[item->processId] = now; - } - } - - item->statsLabel->setText(statsText); - - // Update buttons - if (item->status == ProcessStatus::Running) { - item->cancelButton->setVisible(true); - item->actionButton->setVisible(false); - } else { - item->cancelButton->setVisible(false); - if (item->type == ProcessType::Export && - item->status == ProcessStatus::Completed) { - item->actionButton->setVisible(true); - } - } - } - - showBalloon(); } -void StatusBalloon::showBalloon() +void StatusBalloon::handleShow(bool forceVisible) { - qDebug() << "StatusBalloon::showBalloon" << sender(); QPoint pos = m_button->mapToGlobal( QPoint(m_button->width() / 2, m_button->height())); - balloon(pos, -1, true); + toggleBaloon(pos, -1, forceVisible); } bool StatusBalloon::isProcessRunning(const QUuid &processId) const @@ -602,31 +437,6 @@ void StatusBalloon::onOpenFolderClicked() } } -QString StatusBalloon::formatFileSize(qint64 bytes) const -{ - const qint64 KB = 1024; - const qint64 MB = KB * 1024; - const qint64 GB = MB * 1024; - - if (bytes >= GB) { - return QString("%1 GB").arg( - QString::number(bytes / double(GB), 'f', 2)); - } else if (bytes >= MB) { - return QString("%1 MB").arg( - QString::number(bytes / double(MB), 'f', 1)); - } else if (bytes >= KB) { - return QString("%1 KB").arg( - QString::number(bytes / double(KB), 'f', 0)); - } else { - return QString("%1 B").arg(bytes); - } -} - -QString StatusBalloon::formatTransferRate(qint64 bytesPerSecond) const -{ - return formatFileSize(bytesPerSecond) + "/s"; -} - void StatusBalloon::removeProcessWidget(const QUuid &processId) { QMutexLocker locker(&m_processesMutex); @@ -649,4 +459,35 @@ void StatusBalloon::removeProcessWidget(const QUuid &processId) } } -ZIconWidget *StatusBalloon::getButton() { return m_button; } +void StatusBalloon::handleJobUpdate(ProcessItem *item) +{ + // QMutexLocker locker(&m_processesMutex); + + // Update status label + QString statusText; + if (item->status == ProcessStatus::Running) { + if (!item->currentFile.isEmpty()) { + statusText = item->currentFile; + } else { + statusText = "Processing..."; + } + } else if (item->status == ProcessStatus::Completed) { + statusText = "Completed successfully"; + } else if (item->status == ProcessStatus::Failed) { + statusText = "Failed"; + } else if (item->status == ProcessStatus::Cancelled) { + statusText = "Cancelled"; + } + item->statusLabel->setText(statusText); + + // Update progress bar + // progess should be based on exported bytes vs total bytes of the current + // file + if (item->totalItems > 0) { + int progress = (item->transferredBytes * 100) / item->totalBytes; + item->processWidget->setProgress(progress); + } + + item->processWidget->updateStats(); + item->processWidget->updateButtons(); +} \ No newline at end of file diff --git a/src/statusballoon.h b/src/statusballoon.h index 784b995..288dd2b 100644 --- a/src/statusballoon.h +++ b/src/statusballoon.h @@ -16,6 +16,7 @@ #include #include #include +class BalloonProcess; enum class ProcessType { Export, Upload }; @@ -35,7 +36,7 @@ struct ProcessItem { QDateTime startTime; QDateTime endTime; QString destinationPath; // For export - QWidget *processWidget; + BalloonProcess *processWidget; QLabel *titleLabel; QLabel *statusLabel; QLabel *statsLabel; @@ -45,11 +46,21 @@ struct ProcessItem { std::atomic cancelRequested{false}; }; -class Process : public QWidget +class BalloonProcess : public QWidget { Q_OBJECT public: - explicit Process(QWidget *parent = nullptr); + explicit BalloonProcess(ProcessItem *item, QWidget *parent = nullptr); + + void setProgress(int progress); + void updateStats(); + void updateButtons(); + void done(); + +private: + ProcessItem *m_item; + QDateTime m_lastUpdateTime; + qint64 m_lastBytesTransferred; }; class StatusBalloon : public QBalloonTip @@ -62,35 +73,28 @@ public: // Process management QUuid startExportProcess(const QString &title, int totalItems, const QString &destinationPath); - QUuid startUploadProcess(const QString &title, int totalItems); void onFileTransferProgress(const QUuid &processId, int currentItem, const QString ¤tFile, qint64 bytesTransferred, qint64 totalBytes); - void markProcessCompleted(const QUuid &processId); - void markProcessFailed(const QUuid &processId, const QString &error); - void markProcessCancelled(const QUuid &processId); - void incrementFailedItems(const QUuid &processId); bool isProcessRunning(const QUuid &processId) const; bool hasActiveProcesses() const; bool isCancelRequested(const QUuid &processId) const; - ZIconWidget *getButton(); -private slots: + ZIconWidget *getButton() { return m_button; } void onCancelClicked(); void onOpenFolderClicked(); private: - void updateUI(); - void showBalloon(); + void updateHeader(); + void handleShow(bool forceVisible = false); void createProcessWidget(ProcessItem *item); - QString formatFileSize(qint64 bytes) const; - QString formatTransferRate(qint64 bytesPerSecond) const; void removeProcessWidget(const QUuid &processId); void connectExportThreadSignals(); void onExportFinished(const QUuid &processId, const ExportJobSummary &summary); void onItemExported(const QUuid &processId, const ExportResult &result); + void handleJobUpdate(ProcessItem *item); QVBoxLayout *m_mainLayout; QLabel *m_headerLabel; @@ -101,8 +105,6 @@ private: QUuid m_currentProcessId; mutable QMutex m_processesMutex; - QMap m_lastBytesTransferred; - QMap m_lastUpdateTime; ZIconWidget *m_button = new ZIconWidget(QIcon(":/resources/icons/UimProcess.png"), "Processes"); };