diff --git a/App/Element/GraphicElement.h b/App/Element/GraphicElement.h index 50036e830..799a70ed1 100644 --- a/App/Element/GraphicElement.h +++ b/App/Element/GraphicElement.h @@ -543,7 +543,6 @@ class GraphicElement : public QGraphicsObject, public ItemWithId */ bool simUpdateInputsAllowUnknown(); - /** * \brief Decodes \a count select-line statuses from simInputs() into a binary index. * \param offset Index of the first select-line input in simInputs(). diff --git a/App/Element/IC.cpp b/App/Element/IC.cpp index bfa746f47..35d78b786 100644 --- a/App/Element/IC.cpp +++ b/App/Element/IC.cpp @@ -4,6 +4,7 @@ #include "App/Element/IC.h" #include +#include #include #include #include @@ -15,6 +16,7 @@ #include "App/Core/Common.h" #include "App/Element/ElementFactory.h" #include "App/Element/ElementInfo.h" +#include "App/Element/ICPreviewPopup.h" #include "App/Element/ICRegistry.h" #include "App/IO/Serialization.h" #include "App/IO/SerializationContext.h" @@ -23,9 +25,21 @@ #include "App/Nodes/QNEPort.h" #include "App/Scene/Scene.h" #include "App/Simulation/Simulation.h" +#include "App/UI/MainWindow.h" namespace { +// Returns the shared IC preview popup, or nullptr if the chain isn't fully +// alive (e.g. during early init or late teardown). The popup is created +// eagerly by MainWindow's constructor; null returns are exclusively a +// teardown-safety concern. +ICPreviewPopup *icPreviewPopup() +{ + auto *app = Application::instance(); + auto *mw = app ? app->mainWindow() : nullptr; + return mw ? mw->icPreviewPopup() : nullptr; +} + bool comparePorts(QNEPort *port1, QNEPort *port2) { auto *elem1 = port1->graphicElement(); @@ -112,10 +126,20 @@ IC::IC(QGraphicsItem *parent) // ICs display their label vertically alongside the chip body, matching the physical // convention of reading IC labels along the side of a physical DIP package m_label->setRotation(90); + + // Enable hover events so the preview popup can be shown/hidden + setAcceptHoverEvents(true); } IC::~IC() { + // If this IC is destroyed while the shared popup is pending or showing it, + // cancel immediately — m_pendingIC is a QPointer so it auto-nulls, but the + // popup could still be visible with stale content. + if (auto *popup = icPreviewPopup(); popup && popup->pendingIC() == this) { + popup->cancelHide(); + popup->hide(); + } qDeleteAll(m_internalConnections); qDeleteAll(m_internalElements); } @@ -412,6 +436,12 @@ void IC::migrateFile(const QFileInfo &fileInfo, const QList &it void IC::processLoadedItems(const QList &items) { + // Snapshot the preview now, while the original Input/Output elements (buttons, + // switches, LEDs, …) are still alive in `items`. loadBoundaryElement() below + // replaces each of them with a proxy Node, so a later render would only see + // the simulation graph. + generatePreviewPixmap(items); + for (auto *item : items) { if (auto *conn = qgraphicsitem_cast(item)) { m_internalConnections.append(conn); @@ -537,10 +567,107 @@ void IC::generatePixmap() void IC::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { + // Hide the preview popup immediately on double-click so it doesn't overlap the sub-circuit tab + if (auto *popup = icPreviewPopup()) { + popup->cancelHide(); + popup->hide(); + } event->accept(); emit requestOpenSubCircuit(id(), m_blobName, m_file); } +// --- Hover preview --- + +void IC::hoverEnterEvent(QGraphicsSceneHoverEvent *event) +{ + if (auto *popup = icPreviewPopup()) { + popup->showForIC(this, event->screenPos()); + } +} + +void IC::hoverMoveEvent(QGraphicsSceneHoverEvent *event) +{ + if (auto *popup = icPreviewPopup()) { + popup->updatePendingPos(event->screenPos()); + } +} + +void IC::hoverLeaveEvent(QGraphicsSceneHoverEvent *event) +{ + Q_UNUSED(event) + if (auto *popup = icPreviewPopup()) { + popup->scheduleHide(); + } +} + +void IC::generatePreviewPixmap(const QList &items) +{ + // Split the freshly-deserialized items into elements and connections. The + // boundary Input/Output elements are still in their designed form here; the + // substitution to proxy Nodes happens later in processLoadedItems(). + QVector elements; + QVector connections; + elements.reserve(items.size()); + connections.reserve(items.size()); + for (auto *item : items) { + if (auto *conn = qgraphicsitem_cast(item)) { + connections.append(conn); + } else if (auto *elm = qgraphicsitem_cast(item)) { + elements.append(elm); + } + } + + // Skip for empty or very large circuits. + if (elements.isEmpty() || elements.size() > ICPreviewPopup::MaxElementCount) { + m_previewPixmap = QPixmap(); + return; + } + + // Temporarily borrow the items into a throwaway scene so QGraphicsScene::render() + // can be used without disturbing the real scene. The scope guard guarantees + // cleanup even if render() throws. + QGraphicsScene tempScene; + tempScene.setBackgroundBrush(QColor(42, 42, 42)); + + auto cleanup = qScopeGuard([&] { + for (auto *elm : std::as_const(elements)) { tempScene.removeItem(elm); } + for (auto *conn : std::as_const(connections)) { tempScene.removeItem(conn); } + }); + + for (auto *elm : std::as_const(elements)) { + tempScene.addItem(elm); + } + for (auto *conn : std::as_const(connections)) { + tempScene.addItem(conn); + } + + // Compute the bounding rect with some padding + const QRectF sourceRect = tempScene.itemsBoundingRect().adjusted(-16, -16, 16, 16); + + // Scale to fit within max preview dimensions while preserving aspect ratio + QSize targetSize = sourceRect.size().toSize(); + targetSize.scale(ICPreviewPopup::MaxWidth, ICPreviewPopup::MaxHeight, Qt::KeepAspectRatio); + + if (targetSize.isEmpty()) { + m_previewPixmap = QPixmap(); + return; + } + + // QPixmap(QSize) is uninitialised; tempScene.render() paints the background + // brush over the source→target affine, but subpixel rounding can leave a + // 1-pixel sliver unpainted at the right/bottom edge, exposing whatever was + // in memory (commonly white on Windows). Fill explicitly to avoid that. + QPixmap preview(targetSize); + preview.fill(QColor(42, 42, 42)); + + QPainter painter(&preview); + painter.setRenderHint(QPainter::Antialiasing); + tempScene.render(&painter, QRectF(), sourceRect); + painter.end(); + + m_previewPixmap = preview; +} + QRectF IC::boundingRect() const { return portsBoundingRect().united(QRectF(0, 0, 64, 64)); diff --git a/App/Element/IC.h b/App/Element/IC.h index 9f856b9d5..d9d8a8aec 100644 --- a/App/Element/IC.h +++ b/App/Element/IC.h @@ -8,11 +8,13 @@ #pragma once #include +#include #include #include "App/Element/GraphicElement.h" #include "App/IO/SerializationContext.h" +class QGraphicsSceneHoverEvent; class QNEConnection; /** @@ -66,6 +68,12 @@ class IC : public GraphicElement const QVector &internalInputs() const { return m_internalInputs; } const QVector &internalOutputs() const { return m_internalOutputs; } + /// Returns the cached preview pixmap of the designed circuit. + /// The snapshot is taken once during load (before boundary elements are + /// substituted with proxy Nodes), so the preview reflects what the user + /// actually placed — buttons, switches, LEDs — not the simulation graph. + const QPixmap &previewPixmap() const { return m_previewPixmap; } + // --- Port metadata --- /// Port count and label metadata extracted from Input/Output elements. @@ -112,6 +120,16 @@ class IC : public GraphicElement /// \reimp Opens the IC sub-circuit for editing on double-click. void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + /// \reimp Schedules a floating preview of the internal circuit after a short hover delay. + void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; + + /// \reimp Updates the pending preview position so the popup appears near + /// the current cursor when the show delay elapses, not at the entry point. + void hoverMoveEvent(QGraphicsSceneHoverEvent *event) override; + + /// \reimp Dismisses the floating preview with a short delay. + void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override; + private: // --- Utility methods --- @@ -130,6 +148,7 @@ class IC : public GraphicElement // --- Visual helpers --- void generatePixmap(); + void generatePreviewPixmap(const QList &items); // --- Members --- @@ -145,5 +164,9 @@ class IC : public GraphicElement QVector m_sortedInternalElements; QSet m_boundaryInputElements; bool m_internalHasFeedback = false; + + // --- Members: Hover preview --- + + QPixmap m_previewPixmap; ///< Snapshot of the designed circuit, rendered at load time. }; diff --git a/App/Element/ICPreviewPopup.cpp b/App/Element/ICPreviewPopup.cpp new file mode 100644 index 000000000..1a53f034a --- /dev/null +++ b/App/Element/ICPreviewPopup.cpp @@ -0,0 +1,133 @@ +// Copyright 2015 - 2026, GIBIS-UNIFESP and the wiRedPanda contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "App/Element/ICPreviewPopup.h" + +#include +#include +#include + +#include "App/Element/IC.h" + +ICPreviewPopup::ICPreviewPopup(QWidget *parent) + : QWidget(parent, Qt::ToolTip | Qt::FramelessWindowHint) +{ + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_NoSystemBackground); // Prevent solid black fill on Windows + setAttribute(Qt::WA_ShowWithoutActivating); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(4, 4, 4, 4); + + m_imageLabel = new QLabel(this); + m_imageLabel->setObjectName(QStringLiteral("preview")); + m_imageLabel->setAlignment(Qt::AlignCenter); + layout->addWidget(m_imageLabel); + + // Outer popup chrome + an inset frame around the preview pixmap so the + // rendered circuit reads as a framed image rather than floating content. + // The QLabel selector is scoped by objectName so future labels added to + // the popup don't silently inherit the inset frame styling. + setStyleSheet( + "ICPreviewPopup {" + " background-color: rgba(30, 30, 30, 230);" + " border: 1px solid rgba(120, 120, 120, 180);" + " border-radius: 6px;" + "}" + "QLabel#preview {" + " border: 1px solid rgba(170, 170, 170, 200);" + " border-radius: 3px;" + " padding: 6px;" + " background-color: rgba(15, 15, 15, 200);" + "}" + ); + + m_hideTimer.setSingleShot(true); + m_hideTimer.setInterval(300); + connect(&m_hideTimer, &QTimer::timeout, this, &QWidget::hide); + + m_showTimer.setSingleShot(true); + m_showTimer.setInterval(1000); + connect(&m_showTimer, &QTimer::timeout, this, &ICPreviewPopup::executeShow); +} + +void ICPreviewPopup::showForIC(IC *ic, const QPoint &screenPos) +{ + cancelHide(); + + if (!ic) { + return; + } + + m_pendingIC = ic; + m_pendingPos = screenPos; + + if (isVisible()) { + // If the popup is already visible (e.g., moved quickly from another IC), + // update it immediately without a delay. + executeShow(); + } else { + m_showTimer.start(); + } +} + +void ICPreviewPopup::executeShow() +{ + if (!m_pendingIC) { + return; + } + + // The pixmap is the single source of truth: it's null when the IC was + // empty or oversized at load time, or when generation otherwise failed. + const QPixmap &preview = m_pendingIC->previewPixmap(); + if (preview.isNull()) { + hide(); + return; + } + + m_imageLabel->setPixmap(preview); + adjustSize(); + + // Position slightly offset from the cursor, then clamp to the available + // screen geometry so the popup never extends off-screen. + QPoint pos = m_pendingPos + QPoint(16, 16); + if (const auto *screen = QGuiApplication::screenAt(pos)) { + const QRect avail = screen->availableGeometry(); + pos.setX(qMin(pos.x(), avail.right() - width())); + pos.setY(qMin(pos.y(), avail.bottom() - height())); + } + move(pos); + show(); +} + +void ICPreviewPopup::scheduleHide() +{ + m_showTimer.stop(); + m_hideTimer.start(); +} + +void ICPreviewPopup::cancelHide() +{ + m_hideTimer.stop(); + m_showTimer.stop(); +} + +void ICPreviewPopup::updatePendingPos(const QPoint &screenPos) +{ + if (!isVisible()) { + m_pendingPos = screenPos; + } +} + +void ICPreviewPopup::enterEvent(QEnterEvent *event) +{ + Q_UNUSED(event) + cancelHide(); +} + +void ICPreviewPopup::leaveEvent(QEvent *event) +{ + Q_UNUSED(event) + scheduleHide(); +} + diff --git a/App/Element/ICPreviewPopup.h b/App/Element/ICPreviewPopup.h new file mode 100644 index 000000000..6097f6cf3 --- /dev/null +++ b/App/Element/ICPreviewPopup.h @@ -0,0 +1,79 @@ +// Copyright 2015 - 2026, GIBIS-UNIFESP and the wiRedPanda contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +/** \file + * \brief Frameless popup widget that shows a preview of an IC's internal circuit. + */ + +#pragma once + +#include +#include +#include +#include + +class IC; + +/** + * \class ICPreviewPopup + * \brief Frameless tooltip-like widget showing a rendered preview of an IC's sub-circuit. + * + * \details Displayed on hover over an IC element. The preview is rendered from + * the IC's internal elements and connections into a QPixmap at reduced scale. + * The popup is dismissed with a short delay (300ms) when the mouse leaves either + * the IC or the popup itself. + */ +class ICPreviewPopup : public QWidget +{ + Q_OBJECT + +public: + /// Maximum preview dimensions in pixels. + static constexpr int MaxWidth = 500; + static constexpr int MaxHeight = 350; + + /// Maximum number of internal elements before we skip generating a preview. + /// Empirically chosen: circuits above this size render in >16 ms on a + /// mid-range laptop, making the 1-second hover delay feel unresponsive. + static constexpr int MaxElementCount = 500; + + /// Constructs the popup as a frameless, non-activating child of \a parent. + explicit ICPreviewPopup(QWidget *parent = nullptr); + + /// Schedules the popup to appear near \a screenPos with the preview of \a ic + /// after a 1-second hover delay. If the popup is already visible (e.g. the + /// cursor moved quickly from another IC), the update is applied immediately. + void showForIC(IC *ic, const QPoint &screenPos); + + /// Starts a delayed hide (300ms). If showForIC() is called before the + /// timer fires, the hide is cancelled. + void scheduleHide(); + + /// Cancels a pending show/hide. + void cancelHide(); + + /// Updates the position the popup will appear at when the show timer + /// fires. No effect if the popup is already visible. Use this on + /// hover-move so the popup tracks the cursor during the show delay. + void updatePendingPos(const QPoint &screenPos); + + /// Returns the IC currently pending display (may be null). + IC *pendingIC() const { return m_pendingIC; } + +protected: + /// \reimp Cancels a scheduled hide when the cursor enters the popup itself. + void enterEvent(QEnterEvent *event) override; + + /// \reimp Schedules a hide when the cursor leaves the popup. + void leaveEvent(QEvent *event) override; + +private: + void executeShow(); + + QLabel *m_imageLabel; + QTimer m_hideTimer; + QTimer m_showTimer; + QPointer m_pendingIC; ///< Auto-nulled if the IC is deleted while a timer is pending. + QPoint m_pendingPos; +}; + diff --git a/App/Scene/GraphicsView.cpp b/App/Scene/GraphicsView.cpp index 122a07fa3..0c41ffbec 100644 --- a/App/Scene/GraphicsView.cpp +++ b/App/Scene/GraphicsView.cpp @@ -3,13 +3,13 @@ #include "App/Scene/GraphicsView.h" -#include "App/Core/SentryHelpers.h" - #include #include #include #include +#include "App/Core/SentryHelpers.h" + GraphicsView::GraphicsView(QWidget *parent) : QGraphicsView(parent) { diff --git a/App/UI/MainWindow.cpp b/App/UI/MainWindow.cpp index 0e8764102..3321ab12e 100644 --- a/App/UI/MainWindow.cpp +++ b/App/UI/MainWindow.cpp @@ -51,6 +51,7 @@ #include "App/Element/ElementFactory.h" #include "App/Element/ElementLabel.h" #include "App/Element/IC.h" +#include "App/Element/ICPreviewPopup.h" #include "App/Element/ICRegistry.h" #include "App/IO/RecentFiles.h" #include "App/IO/Serialization.h" @@ -91,6 +92,9 @@ MainWindow::MainWindow(const QString &fileName, QWidget *parent) // Must be created before setupLanguage/setupTheme since both may call palette methods. m_palette = new ElementPalette(m_ui.get(), this); + // Shared IC-hover preview, owned by this MainWindow as a Qt child. + m_icPreviewPopup = new ICPreviewPopup(this); + setupLanguage(); setupGeometry(); setupTheme(); @@ -1312,6 +1316,11 @@ WorkSpace *MainWindow::currentTab() const return m_currentTab; } +ICPreviewPopup *MainWindow::icPreviewPopup() const +{ + return m_icPreviewPopup; +} + void MainWindow::tabChanged(const int newTabIndex) { sentryBreadcrumb("ui", QStringLiteral("Tab changed to index %1").arg(newTabIndex)); diff --git a/App/UI/MainWindow.h b/App/UI/MainWindow.h index b99372c8b..bd4d6f0d0 100644 --- a/App/UI/MainWindow.h +++ b/App/UI/MainWindow.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -19,6 +20,7 @@ class ElementLabel; class ElementPalette; class IC; +class ICPreviewPopup; class LanguageManager; class QShortcut; class RecentFiles; @@ -50,6 +52,10 @@ class MainWindow : public QMainWindow /// Shows the window and initializes child widget state. void show(); + /// Returns the shared IC-hover preview popup. Owned by this MainWindow as + /// a Qt child; auto-nulls (via QPointer) if destroyed before this window. + ICPreviewPopup *icPreviewPopup() const; + // --- Tab Management --- /// Creates a new empty circuit tab. @@ -304,6 +310,10 @@ class MainWindow : public QMainWindow LanguageManager *m_languageManager = nullptr; RecentFiles *m_recentFiles = nullptr; + /// Shared IC-hover preview, parented to this MainWindow. + /// QPointer so accesses during teardown are safe regardless of child-destruction order. + QPointer m_icPreviewPopup; + WorkSpace *m_currentTab = nullptr; int m_tabIndex = -1; diff --git a/CMakeSources.cmake b/CMakeSources.cmake index 7fc69af88..372edcfda 100644 --- a/CMakeSources.cmake +++ b/CMakeSources.cmake @@ -52,6 +52,7 @@ set(SOURCES ${CMAKE_CURRENT_LIST_DIR}/App/Element/GraphicElements/Xor.cpp ${CMAKE_CURRENT_LIST_DIR}/App/Element/GraphicElementSerializer.cpp ${CMAKE_CURRENT_LIST_DIR}/App/Element/IC.cpp + ${CMAKE_CURRENT_LIST_DIR}/App/Element/ICPreviewPopup.cpp ${CMAKE_CURRENT_LIST_DIR}/App/Element/ICRegistry.cpp ${CMAKE_CURRENT_LIST_DIR}/App/IO/RecentFiles.cpp ${CMAKE_CURRENT_LIST_DIR}/App/IO/Serialization.cpp @@ -161,6 +162,7 @@ set(HEADERS ${CMAKE_CURRENT_LIST_DIR}/App/Element/GraphicElements/Xnor.h ${CMAKE_CURRENT_LIST_DIR}/App/Element/GraphicElements/Xor.h ${CMAKE_CURRENT_LIST_DIR}/App/Element/IC.h + ${CMAKE_CURRENT_LIST_DIR}/App/Element/ICPreviewPopup.h ${CMAKE_CURRENT_LIST_DIR}/App/Element/ICRegistry.h ${CMAKE_CURRENT_LIST_DIR}/App/Element/PropertyDescriptor.h ${CMAKE_CURRENT_LIST_DIR}/App/IO/FileUtils.h diff --git a/Tests/Unit/Simulation/TestDanglingPointer.cpp b/Tests/Unit/Simulation/TestDanglingPointer.cpp index 9cd663576..f4af5b745 100644 --- a/Tests/Unit/Simulation/TestDanglingPointer.cpp +++ b/Tests/Unit/Simulation/TestDanglingPointer.cpp @@ -379,3 +379,4 @@ void TestDanglingPointer::integration_simulationTickAfterResetMustNotCrash() ws.scene()->simulation()->update(); QVERIFY(true); } + diff --git a/Tests/Unit/Simulation/TestDanglingPointer.h b/Tests/Unit/Simulation/TestDanglingPointer.h index 55b57f799..f9bbef1a4 100644 --- a/Tests/Unit/Simulation/TestDanglingPointer.h +++ b/Tests/Unit/Simulation/TestDanglingPointer.h @@ -72,3 +72,4 @@ private slots: /// Simulation.cpp:87's `element->updateLogic()` took in production. void integration_simulationTickAfterResetMustNotCrash(); }; + diff --git a/Tests/Unit/Ui/TestLanguageManager.cpp b/Tests/Unit/Ui/TestLanguageManager.cpp index 7f09693cb..eab823ade 100644 --- a/Tests/Unit/Ui/TestLanguageManager.cpp +++ b/Tests/Unit/Ui/TestLanguageManager.cpp @@ -3,14 +3,14 @@ #include "Tests/Unit/Ui/TestLanguageManager.h" -#include "App/UI/LanguageManager.h" -#include "Tests/Common/TestUtils.h" - #include #include #include #include +#include "App/UI/LanguageManager.h" +#include "Tests/Common/TestUtils.h" + void TestLanguageManager::testAvailableLanguages() { LanguageManager manager;