From fc6aded0091e4014ef113b89f9d74a3262a908bc Mon Sep 17 00:00:00 2001 From: OpenAI Date: Sat, 29 Aug 2026 08:17:24 +0000 Subject: [PATCH 1/8] qtgui: make workspace chrome and panel detachment uniform --- .agents/qtgui.md | 26 ++- src/qtgui/ImageViewWindow.cpp | 156 -------------- src/qtgui/ImageViewWindow.h | 10 - src/qtgui/MainWindow.cpp | 373 ++++----------------------------- src/qtgui/MainWindow.h | 39 ++-- src/qtgui/ParameterPanel.cpp | 311 ++++++++++++++++++++++----- src/qtgui/ParameterPanel.h | 11 +- src/qtgui/TilePreviewPanel.cpp | 1 - src/qtgui/WorkspaceWindow.cpp | 240 ++++++++++++++++++--- src/qtgui/WorkspaceWindow.h | 43 +++- src/qtgui/main.cpp | 113 ++++++++-- 11 files changed, 689 insertions(+), 634 deletions(-) diff --git a/.agents/qtgui.md b/.agents/qtgui.md index a8584568..5bd7e122 100644 --- a/.agents/qtgui.md +++ b/.agents/qtgui.md @@ -22,8 +22,8 @@ background work and cancellation state. ### Key Components - **`ColorScreenApplication`**: The application-level document manager. It creates and tracks `MainWindow` instances, routes open requests, manages tab/detached presentation, cycles between documents, and restores per-document crash-recovery sessions. -- **`WorkspaceWindow`**: The primary top-level application shell. Its central `QMdiArea` provides tabbed and subwindow views, while the shell temporarily presents the active document's menu bar, toolbar, and inspector. It owns presentation state only, never image-processing state. -- **`MainWindow`**: One complete image document. It owns the scan, parameters, undo stack, image/navigation widgets, panels, task queues, workers, progress entries, detached docks, and a unique recovery directory. Mutable document state must never be shared between different `MainWindow` instances. +- **`WorkspaceWindow`**: The primary top-level application shell. Its central `QMdiArea` provides tabbed and subwindow views, while the shell temporarily presents the active document's menu bar, toolbar, and inspector. It permanently hosts progress presentation for every logical document represented by an attached primary or secondary view. It owns presentation state only, never image-processing state. +- **`MainWindow`**: One complete image document. It owns the scan, parameters, undo stack, image/navigation widgets, panels, task queues, workers, progress entries, and a unique recovery directory. Mutable document state must never be shared between different `MainWindow` instances. - **`NavigationView`**: This shows the whole image and indicates zoom and position of ImageWidget. It lets user to effectively move around the image - **`ImageWidget`**: This displays the image and provides high-performance interaction. It uses a modular architecture for rendering and event handling to manage complex interaction modes (Pan, Select, SetCenter, etc.). @@ -69,9 +69,12 @@ dock. The workspace owns the one status bar for the whole top-level window. Every attached `MainWindow` and `ImageViewWindow` routes `statusBar()` directly to that same `QStatusBar`; tabs must not keep private status-message state or mirror messages when activation changes. Only detached top-level windows use -their private status bars. The active document's transient progress controls are -temporarily moved into the shared status line, while dedicated long-running task -rows remain in the workspace task strip. Their Stop/Cancel buttons use +their private status bars. Every represented document's transient progress controls are attached to the +workspace once, independently of active chrome. The shared status line displays +the most recently started visible document task and provides document-level +previous/next controls when several documents work concurrently; changing tabs +must never choose or hide progress. Dedicated long-running task rows remain in +the workspace task strip. Their Stop/Cancel buttons use `Qt::TabFocus`: keyboard users can reach them, but a mouse click must not steal keyboard focus from the active MDI image. Before a keyboard-focused task control is disabled or its row is removed, return focus to the image presentation that is @@ -134,11 +137,14 @@ uses the source `MainWindow` as the authoritative parameter/undo/recovery model. It never owns or suggests another `.par` file. Its inspector keeps the standard `NavigationView` above a single **Sharpness** tab, and rendering is limited to **Original digital capture** and **Image layer**. Measurements are -applied through the source document's undoable parameter path. Detachable -Sharpness diagnostics (MTF, dot spread, finetune images, and adaptive-sharpening -charts) are presentation owned by that reference view and detach into its own -floating docks; they must never be handed to the source document's diagnostic -docks. **Reload and demosaic** reloads both the source scan and every associated +applied through the source document's undoable parameter path. Every panel section created by `ParameterPanel::createDetachableSection()` owns +its own standard floating-dock lifecycle. This is the only detach implementation: +document windows, ordinary views, and specialized reference views do not create +parallel chart docks or probe nested layouts during reattachment. A detached +section follows the top-level window currently presenting its inspector and always +returns its content when closed. Sharpness diagnostics in a slanted-edge reference +therefore remain presentation-owned by that reference inspector without any +special-case dock wiring. **Reload and demosaic** reloads both the source scan and every associated slanted-edge reference from its own filename using the current demosaic mode. **Window → New View** creates another MDI view of the same document. Ordinary diff --git a/src/qtgui/ImageViewWindow.cpp b/src/qtgui/ImageViewWindow.cpp index bf963d6b..d220f472 100644 --- a/src/qtgui/ImageViewWindow.cpp +++ b/src/qtgui/ImageViewWindow.cpp @@ -46,88 +46,6 @@ QIcon viewIcon(const char *resource) { return QIcon(QString::fromLatin1(resource)); } -/** Run CALLBACK when a floating reference-view chart dock is closed. */ -class ReferenceDockCloseEventFilter final : public QObject { -public: - ReferenceDockCloseEventFilter(QObject *parent, std::function callback) - : QObject(parent), m_callback(std::move(callback)) {} - -protected: - bool eventFilter(QObject *object, QEvent *event) override { - if (event && event->type() == QEvent::Close && m_callback) - m_callback(); - return QObject::eventFilter(object, event); - } - -private: - std::function m_callback; -}; - -/** Move WIDGET into a floating DOCK using the standard resizable wrapper. */ -void detachReferenceWidget(QDockWidget *dock, QWidget *widget) { - if (!dock || !widget || dock->widget()) - return; - - auto *wrapper = new QFrame(); - wrapper->setObjectName(QStringLiteral("DetachedReferenceWrapper")); - wrapper->setFrameStyle(QFrame::Box | QFrame::Plain); - wrapper->setLineWidth(1); - wrapper->setStyleSheet( - QStringLiteral("QFrame#DetachedReferenceWrapper { border: 1px solid #555; " - "background: Palette(Window); }")); - - auto *outerLayout = new QVBoxLayout(wrapper); - outerLayout->setContentsMargins(1, 1, 1, 1); - outerLayout->setSpacing(0); - - auto *container = new QWidget(wrapper); - auto *gridLayout = new QGridLayout(container); - gridLayout->setContentsMargins(0, 0, 0, 0); - gridLayout->setSpacing(0); - gridLayout->addWidget(widget, 0, 0); - gridLayout->addWidget(new QSizeGrip(container), 0, 0, - Qt::AlignRight | Qt::AlignBottom); - outerLayout->addWidget(container); - - dock->setWidget(wrapper); - widget->show(); - dock->setFloating(true); - dock->show(); - if (widget->sizeHint().isValid()) { - const QSize size = widget->sizeHint(); - dock->resize(size.width() + 4, size.height() + 4); - } -} - -/** Reattach the non-grip widget currently hosted by DOCK through REATTACH. */ -void reattachReferenceWidget( - QDockWidget *dock, const std::function &reattach) { - if (!dock || !dock->widget()) - return; - - QWidget *wrapper = dock->widget(); - QWidget *originalWidget = nullptr; - if (wrapper->layout() && wrapper->layout()->count() > 0) { - QLayoutItem *containerItem = wrapper->layout()->itemAt(0); - QWidget *container = containerItem ? containerItem->widget() : nullptr; - if (container && container->layout()) { - QLayout *layout = container->layout(); - for (int i = 0; i < layout->count(); ++i) { - QWidget *child = layout->itemAt(i)->widget(); - if (child && !qobject_cast(child)) { - originalWidget = child; - break; - } - } - } - } - - if (originalWidget && reattach) - reattach(originalWidget); - dock->setWidget(nullptr); - wrapper->deleteLater(); -} - } // namespace /** Return the one status bar belonging to the current top-level window. */ @@ -230,7 +148,6 @@ ImageViewWindow::~ImageViewWindow() { m_referenceLoadCondition.wait( locker, [this]() { return !m_referenceWorkerActive; }); } - restoreReferenceChartDocks(); releaseDocumentInspector(); } @@ -504,7 +421,6 @@ void ImageViewWindow::setupReferenceInspector() { }); connect(m_sharpnessPanel, &SharpnessPanel::measureMtfRequested, this, &ImageViewWindow::onMeasureMtfRequested); - setupReferenceChartDocks(); splitter->setStretchFactor(0, 0); splitter->setStretchFactor(1, 1); @@ -522,79 +438,7 @@ void ImageViewWindow::setupReferenceInspector() { } /** Create floating diagnostic docks owned by this specialized reference view. */ -void ImageViewWindow::setupReferenceChartDocks() { - if (!m_slantedEdgeReference || !m_sharpnessPanel) - return; - - auto createDock = [this](const QString &title, const QString &objectName, - Qt::DockWidgetArea area) { - auto *dock = new QDockWidget(title, this); - dock->setObjectName(objectName); - dock->setVisible(false); - addDockWidget(area, dock); - return dock; - }; - - m_referenceMtfDock = - createDock(tr("MTF Chart"), QStringLiteral("SlantedEdgeMTFChartDock"), - Qt::BottomDockWidgetArea); - m_referenceDotSpreadDock = createDock( - tr("Dot Spread Function"), QStringLiteral("SlantedEdgeDotSpreadDock"), - Qt::BottomDockWidgetArea); - m_referenceFinetuneDock = createDock( - tr("Finetune Diagnostic Images"), - QStringLiteral("SlantedEdgeFinetuneImagesDock"), - Qt::RightDockWidgetArea); - m_referenceAdaptiveDock = createDock( - tr("Adaptive Sharpening"), QStringLiteral("SlantedEdgeAdaptiveDock"), - Qt::BottomDockWidgetArea); - - auto setupDock = [this](QDockWidget *dock, auto detachSignal, - auto reattachMethod) { - connect(m_sharpnessPanel, detachSignal, this, - [dock](QWidget *widget) { detachReferenceWidget(dock, widget); }); - dock->installEventFilter(new ReferenceDockCloseEventFilter( - dock, [this, dock, reattachMethod]() { - if (!m_sharpnessPanel) - return; - reattachReferenceWidget(dock, [this, reattachMethod](QWidget *widget) { - (m_sharpnessPanel->*reattachMethod)(widget); - }); - })); - }; - - setupDock(m_referenceMtfDock, &SharpnessPanel::detachMTFChartRequested, - &SharpnessPanel::reattachMTFChart); - setupDock(m_referenceDotSpreadDock, - &SharpnessPanel::detachDotSpreadRequested, - &SharpnessPanel::reattachDotSpread); - setupDock(m_referenceFinetuneDock, - &SharpnessPanel::detachFinetuneImagesRequested, - &SharpnessPanel::reattachFinetuneImages); - setupDock(m_referenceAdaptiveDock, - &SharpnessPanel::detachAdaptiveChartRequested, - &SharpnessPanel::reattachAdaptiveChart); -} - /** Return any floating reference diagnostics to the Sharpness panel. */ -void ImageViewWindow::restoreReferenceChartDocks() { - if (!m_sharpnessPanel) - return; - - reattachReferenceWidget(m_referenceMtfDock, [this](QWidget *widget) { - m_sharpnessPanel->reattachMTFChart(widget); - }); - reattachReferenceWidget(m_referenceDotSpreadDock, [this](QWidget *widget) { - m_sharpnessPanel->reattachDotSpread(widget); - }); - reattachReferenceWidget(m_referenceFinetuneDock, [this](QWidget *widget) { - m_sharpnessPanel->reattachFinetuneImages(widget); - }); - reattachReferenceWidget(m_referenceAdaptiveDock, [this](QWidget *widget) { - m_sharpnessPanel->reattachAdaptiveChart(widget); - }); -} - /** Load the reference scan asynchronously without touching document filenames. */ void ImageViewWindow::loadReferenceImage(const QString &fileName) { if (!m_slantedEdgeReference || fileName.isEmpty() || m_referenceLoadPending) diff --git a/src/qtgui/ImageViewWindow.h b/src/qtgui/ImageViewWindow.h index dd5de193..2c0de37f 100644 --- a/src/qtgui/ImageViewWindow.h +++ b/src/qtgui/ImageViewWindow.h @@ -149,12 +149,6 @@ private slots: /** Build the reduced Sharpness-only inspector for a reference image. */ void setupReferenceInspector(); - /** Create view-owned floating docks for detachable Sharpness diagnostics. */ - void setupReferenceChartDocks(); - - /** Reattach any floating reference diagnostics before view teardown. */ - void restoreReferenceChartDocks(); - /** Move the owning document's full inspector into a detached ordinary view. */ void claimDocumentInspector(); @@ -195,10 +189,6 @@ private slots: MultiLineTabWidget *m_referenceTabs = nullptr; SharpnessPanel *m_sharpnessPanel = nullptr; QDockWidget *m_referenceInspectorDock = nullptr; - QDockWidget *m_referenceMtfDock = nullptr; - QDockWidget *m_referenceDotSpreadDock = nullptr; - QDockWidget *m_referenceFinetuneDock = nullptr; - QDockWidget *m_referenceAdaptiveDock = nullptr; QDockWidget *m_documentInspectorDock = nullptr; QWidget *m_documentInspectorHost = nullptr; QPointer m_workspaceStatusBar; diff --git a/src/qtgui/MainWindow.cpp b/src/qtgui/MainWindow.cpp index d9b427c4..f28628d8 100644 --- a/src/qtgui/MainWindow.cpp +++ b/src/qtgui/MainWindow.cpp @@ -553,41 +553,15 @@ MainWindow::~MainWindow() { m_mainSplitter = nullptr; } - // Also manually delete docks as they might hold detached panels - if (m_mtfDock) - delete m_mtfDock; - if (m_dotSpreadDock) - delete m_dotSpreadDock; - if (m_spectraDock) - delete m_spectraDock; - if (m_tilesDock) - delete m_tilesDock; - if (m_colorTilesDock) - delete m_colorTilesDock; - if (m_correctedColorTilesDock) - delete m_correctedColorTilesDock; - if (m_screenPreviewDock) - delete m_screenPreviewDock; - if (m_deformationDock) - delete m_deformationDock; - if (m_lensDock) - delete m_lensDock; - if (m_perspectiveDock) - delete m_perspectiveDock; - if (m_nonlinearDock) - delete m_nonlinearDock; - if (m_gamutDock) - delete m_gamutDock; } /** Build the entire main window UI. Creates the horizontal splitter (image widget | right column), the right - column (navigation view + tab widget with all panels), all dock widgets - for detachable charts and diagnostic images, the status bar with progress - reporting, and wires up the extensive network of signal/slot connections - between panels, ImageWidget, NavigationView, and MainWindow. The dock - wiring uses a generic setupDock lambda that handles detach (wrapping the - widget in a resizable frame) and reattach (via DockCloseEventFilter). */ + column (navigation view + tab widget with all panels), the status bar with + document progress reporting, and the signal/slot connections between + panels, ImageWidget, NavigationView, and MainWindow. Detachable panel + sections own their dock lifecycle in ParameterPanel; the document window + no longer duplicates that presentation machinery. */ void MainWindow::setupUi() { m_mainSplitter = new QSplitter(Qt::Horizontal, this); @@ -658,7 +632,6 @@ void MainWindow::setupUi() { }, [this]() { return m_scan; }, this); - // Create Color Panel (after Sharpness) // Create Color Panel (after Sharpness) m_contactCopyPanel = new ContactCopyPanel( [this]() { return getCurrentState(); }, @@ -717,247 +690,16 @@ void MainWindow::setupUi() { &MainWindow::removeProgress); m_configTabs->setObjectName("ConfigTabs"); - m_mtfDock = new QDockWidget("MTF Chart", this); - - m_mtfDock->setObjectName("MTFChartDock"); - m_mtfDock->setVisible(false); - addDockWidget(Qt::BottomDockWidgetArea, m_mtfDock); - - m_adaptiveSharpeningDock = new QDockWidget("Adaptive Sharpening", this); - m_adaptiveSharpeningDock->setObjectName("adaptiveSharpeningDock"); - m_adaptiveSharpeningDock->setVisible(false); - addDockWidget(Qt::BottomDockWidgetArea, m_adaptiveSharpeningDock); - - m_tilesDock = new QDockWidget("Sharpness Preview", this); - m_tilesDock->setObjectName("TilesDock"); - m_tilesDock->setVisible(false); - addDockWidget(Qt::BottomDockWidgetArea, m_tilesDock); - - // Create Docks for Color components - m_colorTilesDock = new QDockWidget("Color Preview", this); - m_colorTilesDock->setObjectName("ColorTilesDock"); - m_colorTilesDock->setVisible(false); - addDockWidget(Qt::BottomDockWidgetArea, m_colorTilesDock); - - m_correctedColorTilesDock = new QDockWidget("Corrected Color Preview", this); - m_correctedColorTilesDock->setObjectName("CorrectedColorPreviewDock"); - m_correctedColorTilesDock->setAllowedAreas(Qt::AllDockWidgetAreas); - addDockWidget(Qt::RightDockWidgetArea, m_correctedColorTilesDock); - m_correctedColorTilesDock->hide(); // Initially hidden - - // Screen Preview Dock - m_screenPreviewDock = new QDockWidget("Screen Preview", this); - m_screenPreviewDock->setObjectName("ScreenPreviewDock"); - m_screenPreviewDock->setAllowedAreas(Qt::AllDockWidgetAreas); - addDockWidget(Qt::RightDockWidgetArea, m_screenPreviewDock); - m_screenPreviewDock->hide(); // Initially hidden - - // Connection for Color Panel Spectra Chart - m_spectraDock = new QDockWidget("Spectral Transmitance", this); - m_spectraDock->setObjectName("SpectraDock"); - addDockWidget(Qt::RightDockWidgetArea, m_spectraDock); - m_spectraDock->hide(); // Initially hidden - - // Gamut Dock - m_gamutDock = new QDockWidget("Gamut", this); - m_gamutDock->setObjectName("GamutDock"); - addDockWidget(Qt::RightDockWidgetArea, m_gamutDock); - m_gamutDock->hide(); - - // Corrected Gamut Dock - m_correctedGamutDock = new QDockWidget("Corrected Gamut", this); - m_correctedGamutDock->setObjectName("CorrectedGamutDock"); - addDockWidget(Qt::RightDockWidgetArea, m_correctedGamutDock); - m_correctedGamutDock->hide(); - - // H&D Curve Dock - m_hdCurveDock = new QDockWidget("H&D Curve", this); - m_hdCurveDock->setObjectName("HDCurveDock"); - addDockWidget(Qt::RightDockWidgetArea, m_hdCurveDock); - m_hdCurveDock->hide(); - - // Deformation Chart Dock - m_deformationDock = new QDockWidget("Deformation Visualization", this); - m_deformationDock->setObjectName("DeformationDock"); - addDockWidget(Qt::RightDockWidgetArea, m_deformationDock); - m_deformationDock->hide(); - - m_toneCurveDock = new QDockWidget("Tone Curve", this); - m_toneCurveDock->setObjectName("ToneCurveDock"); - addDockWidget(Qt::RightDockWidgetArea, m_toneCurveDock); - m_toneCurveDock->hide(); - - m_lensDock = new QDockWidget("Lens Correction", this); - m_lensDock->setObjectName("LensDock"); - addDockWidget(Qt::RightDockWidgetArea, m_lensDock); - m_lensDock->hide(); - - m_perspectiveDock = new QDockWidget("Perspective", this); - m_perspectiveDock->setObjectName("PerspectiveDock"); - addDockWidget(Qt::RightDockWidgetArea, m_perspectiveDock); - m_perspectiveDock->hide(); - - m_nonlinearDock = new QDockWidget("Nonlinear transformation", this); - m_nonlinearDock->setObjectName("NonlinearDock"); - addDockWidget(Qt::RightDockWidgetArea, m_nonlinearDock); - m_nonlinearDock->hide(); - - m_backlightDock = new QDockWidget("Backlight", this); - m_backlightDock->setObjectName("BacklightDock"); - m_backlightDock->setVisible(false); - addDockWidget(Qt::RightDockWidgetArea, m_backlightDock); - - m_finetuneImagesDock = - new QDockWidget("Finetune Diagnostic Images (Geometry)", this); - m_sharpnessFinetuneImagesDock = - new QDockWidget("Finetune Diagnostic Images (Sharpness)", this); - m_finetuneImagesDock->setObjectName("FinetuneImagesDock"); - m_finetuneImagesDock->setVisible(false); - addDockWidget(Qt::RightDockWidgetArea, m_finetuneImagesDock); - - m_sharpnessFinetuneImagesDock->setObjectName("SharpnessFinetuneImagesDock"); - m_sharpnessFinetuneImagesDock->setVisible(false); - addDockWidget(Qt::RightDockWidgetArea, m_sharpnessFinetuneImagesDock); - - // Event Filter for robust Close detection - class DockCloseEventFilter : public QObject { - std::function m_onClose; - - public: - DockCloseEventFilter(QObject *parent, std::function onClose) - : QObject(parent), m_onClose(onClose) {} - - protected: - bool eventFilter(QObject *obj, QEvent *event) override { - if (event->type() == QEvent::Close) { - if (m_onClose) - m_onClose(); - } - return QObject::eventFilter(obj, event); - } - }; - - // Generic helper for docking connections - auto setupDock = [this](QDockWidget *dock, auto *panel, auto detachSignal, - auto reattachMethod) { - // Connect Detach - connect(panel, detachSignal, this, [dock](QWidget *w) { - if (!w) - return; - - // Wrap widget in a frame to provide better resize borders - QFrame *wrapper = new QFrame(); - wrapper->setObjectName("DetachedWrapper"); - wrapper->setFrameStyle(QFrame::Box | QFrame::Plain); - wrapper->setLineWidth(1); - // Modern sleek border - wrapper->setStyleSheet("QFrame#DetachedWrapper { border: 1px solid #555; " - "background: Palette(Window); }"); - - QVBoxLayout *outerLayout = new QVBoxLayout(wrapper); - outerLayout->setContentsMargins(1, 1, 1, 1); - outerLayout->setSpacing(0); - - QWidget *container = new QWidget(); - QGridLayout *gLayout = new QGridLayout(container); - gLayout->setContentsMargins(0, 0, 0, 0); - gLayout->setSpacing(0); - - gLayout->addWidget(w, 0, 0); - - QSizeGrip *grip = new QSizeGrip(container); - gLayout->addWidget(grip, 0, 0, Qt::AlignRight | Qt::AlignBottom); - - outerLayout->addWidget(container); - - dock->setWidget(wrapper); - w->show(); // Ensure widget is visible - dock->setFloating(true); - dock->show(); - - // Use size hint of the original widget + some margins - if (w->sizeHint().isValid()) { - QSize s = w->sizeHint(); - dock->resize(s.width() + 4, s.height() + 4); - } - }); - // Connect Close/Reattach via Event Filter - dock->installEventFilter( - new DockCloseEventFilter(dock, [dock, panel, reattachMethod]() { - if (dock->widget()) { - // Find the original widget inside the wrapper - QWidget *wrapper = dock->widget(); - QWidget *originalWidget = nullptr; - // The structure is Wrapper -> QVBoxLayout -> Container -> - // QGridLayout -> Widget - if (wrapper->layout() && wrapper->layout()->count() > 0) { - QLayoutItem *containerItem = wrapper->layout()->itemAt(0); - if (containerItem && containerItem->widget() && - containerItem->widget()->layout()) { - QLayout *gLayout = containerItem->widget()->layout(); - for (int i = 0; i < gLayout->count(); ++i) { - QWidget *child = gLayout->itemAt(i)->widget(); - if (child && !qobject_cast(child)) { - originalWidget = child; - break; - } - } - } - } - if (originalWidget) { - (panel->*reattachMethod)(originalWidget); - } - dock->setWidget(nullptr); - wrapper->deleteLater(); - } - })); - }; - - // Wire up docks - setupDock(m_mtfDock, m_sharpnessPanel, - &SharpnessPanel::detachMTFChartRequested, - &SharpnessPanel::reattachMTFChart); - m_dotSpreadDock = new QDockWidget("Dot Spread Function", this); - m_dotSpreadDock->setObjectName("DotSpreadDock"); - m_dotSpreadDock->setVisible(false); - addDockWidget(Qt::BottomDockWidgetArea, m_dotSpreadDock); - setupDock(m_dotSpreadDock, m_sharpnessPanel, - &SharpnessPanel::detachDotSpreadRequested, - &SharpnessPanel::reattachDotSpread); - setupDock(m_tilesDock, m_sharpnessPanel, - &SharpnessPanel::detachTilesRequested, - &SharpnessPanel::reattachTiles); - setupDock(m_colorTilesDock, m_colorPanel, &ColorPanel::detachTilesRequested, - &ColorPanel::reattachTiles); - setupDock(m_correctedColorTilesDock, m_colorPanel, - &ColorPanel::detachCorrectedTilesRequested, - &ColorPanel::reattachCorrectedTiles); - setupDock(m_spectraDock, m_colorPanel, - &ColorPanel::detachSpectraChartRequested, - &ColorPanel::reattachSpectraChart); - setupDock(m_gamutDock, m_colorPanel, &ColorPanel::detachGamutChartRequested, - &ColorPanel::reattachGamutChart); - setupDock(m_correctedGamutDock, m_colorPanel, - &ColorPanel::detachCorrectedGamutChartRequested, - &ColorPanel::reattachCorrectedGamutChart); - - setupDock(m_toneCurveDock, m_colorPanel, - &ColorPanel::detachToneCurveRequested, - &ColorPanel::reattachToneCurve); - - setupDock(m_hdCurveDock, m_contactCopyPanel, - &ContactCopyPanel::detachHDCurveRequested, - &ContactCopyPanel::reattachHDCurve); connect(m_sharpnessPanel, &SharpnessPanel::focusAnalysisRequested, this, &MainWindow::onFocusAnalysisRequested); @@ -974,9 +716,6 @@ void MainWindow::setupUi() { connect(m_sharpnessPanel, &SharpnessPanel::measureMtfRequested, this, &MainWindow::onMeasureMtfRequested); - setupDock(m_screenPreviewDock, m_screenPanel, - &ScreenPanel::detachPreviewRequested, - &ScreenPanel::reattachPreview); // Create Digital Capture Panel m_capturePanel = @@ -996,33 +735,12 @@ void MainWindow::setupUi() { }, [this]() { return m_scan; }, this); - setupDock(m_deformationDock, m_geometryPanel, - &GeometryPanel::detachDeformationChartRequested, - &GeometryPanel::reattachDeformationChart); - setupDock(m_lensDock, m_geometryPanel, - &GeometryPanel::detachLensChartRequested, - &GeometryPanel::reattachLensChart); - setupDock(m_perspectiveDock, m_geometryPanel, - &GeometryPanel::detachPerspectiveChartRequested, - &GeometryPanel::reattachPerspectiveChart); - setupDock(m_nonlinearDock, m_geometryPanel, - &GeometryPanel::detachNonlinearChartRequested, - &GeometryPanel::reattachNonlinearChart); - setupDock(m_finetuneImagesDock, m_geometryPanel, - &GeometryPanel::detachFinetuneImagesRequested, - &GeometryPanel::reattachFinetuneImages); - setupDock(m_sharpnessFinetuneImagesDock, m_sharpnessPanel, - &SharpnessPanel::detachFinetuneImagesRequested, - &SharpnessPanel::reattachFinetuneImages); - setupDock(m_adaptiveSharpeningDock, m_sharpnessPanel, - &SharpnessPanel::detachAdaptiveChartRequested, - &SharpnessPanel::reattachAdaptiveChart); m_configTabs->addTab(m_capturePanel, "Digital capture"); m_configTabs->addTab(m_tilesPanel, "Tiles"); @@ -1034,9 +752,6 @@ void MainWindow::setupUi() { &MainWindow::onFlatFieldRequested); connect(m_capturePanel, &CapturePanel::autodetectRequested, this, &MainWindow::onAutodetectScreen); - setupDock(m_backlightDock, m_capturePanel, - &CapturePanel::detachBacklightRequested, - &CapturePanel::reattachBacklight); connect(m_imageWidget, &ImageWidget::interactionModeChanged, this, [this](ImageWidget::InteractionMode mode) { @@ -1398,6 +1113,7 @@ void MainWindow::setupUi() { m_progressContainer->setMinimumHeight(m_transientProgressRow->sizeHint().height()); m_transientProgressRow->hide(); + m_progressContainer->hide(); statusBar->addPermanentWidget(m_progressContainer, 1); // Initialize manual selection tracking @@ -2705,7 +2421,7 @@ void MainWindow::removeProgress( const std::vector transient = transientProgresses(); if (transient.empty()) { - m_transientProgressRow->hide(); + setTransientProgressVisible(false); m_currentlyDisplayedProgress.reset(); m_manuallySelectedProgressIndex = -1; } else if (m_manuallySelectedProgressIndex >= (int)transient.size()) { @@ -2799,6 +2515,18 @@ void MainWindow::updateProgressWidgets(const ProgressEntry &entry, QLabel *label } } +/** Show or hide this document's one-line transient progress presentation. */ +void MainWindow::setTransientProgressVisible(bool visible) { + if (m_transientProgressRow) + m_transientProgressRow->setVisible(visible); + if (m_progressContainer) + m_progressContainer->setVisible(visible); + if (m_transientProgressVisible == visible) + return; + m_transientProgressVisible = visible; + emit transientProgressVisibilityChanged(visible); +} + /** Synchronize the one-line transient status and dedicated task dock. */ void MainWindow::updateProgressContainerVisibility() { bool hasUserVisibleRows = false; @@ -2818,16 +2546,12 @@ void MainWindow::updateProgressContainerVisibility() { if (visibilityChanged) emit userVisibleProgressVisibilityChanged(hasUserVisibleRows); - const bool transientVisible = - m_transientProgressRow && !m_transientProgressRow->isHidden(); - // m_progressContainer->setVisible(transientVisible); } /** Periodically update transient progress and every dedicated long-task row. */ void MainWindow::onProgressTimer() { if (m_activeProgresses.empty()) { - m_transientProgressRow->hide(); - // m_progressContainer->hide(); + setTransientProgressVisible(false); m_currentlyDisplayedProgress.reset(); m_manuallySelectedProgressIndex = -1; m_progressTimer->stop(); @@ -2853,7 +2577,7 @@ void MainWindow::onProgressTimer() { const std::vector transient = transientProgresses(); if (transient.empty()) { - m_transientProgressRow->hide(); + setTransientProgressVisible(false); m_currentlyDisplayedProgress.reset(); m_manuallySelectedProgressIndex = -1; updateProgressContainerVisibility(); @@ -2878,7 +2602,7 @@ void MainWindow::onProgressTimer() { } if (!task) { - m_transientProgressRow->hide(); + setTransientProgressVisible(false); updateProgressContainerVisibility(); return; } @@ -2894,9 +2618,9 @@ void MainWindow::onProgressTimer() { if (task->startTime.elapsed() > 300) { updateProgressWidgets(*task, m_statusLabel, m_progressBar, QString()); - m_transientProgressRow->show(); + setTransientProgressVisible(true); } else { - m_transientProgressRow->hide(); + setTransientProgressVisible(false); } updateProgressContainerVisibility(); @@ -3096,6 +2820,26 @@ void MainWindow::refreshWindowMenu() { application->populateWindowMenu(m_windowMenu, this); } +/** Remove transient progress from the private status bar for workspace hosting. */ +QWidget *MainWindow::takeWorkspaceStatusWidget() { + if (!m_progressContainer) + return nullptr; + standaloneStatusBar()->removeWidget(m_progressContainer); + m_progressContainer->setParent(nullptr); + return m_progressContainer; +} + +/** Return transient progress to this document's private status bar. */ +void MainWindow::restoreWorkspaceStatusWidget() { + if (!m_progressContainer) + return; + if (m_progressContainer->parentWidget() != standaloneStatusBar()) { + m_progressContainer->setParent(standaloneStatusBar()); + standaloneStatusBar()->addPermanentWidget(m_progressContainer, 1); + } + m_progressContainer->setVisible(m_transientProgressVisible); +} + /** Remove persistent progress rows from the local task-progress dock. */ QWidget *MainWindow::takeUserVisibleStatusWidget() { if (!m_userVisibleProgressContainer || !m_userVisibleProgressDock) @@ -3835,14 +3579,6 @@ void MainWindow::updateUIFromState(const ParameterState &state) { if (m_geometryPanel) { m_geometryPanel->updateDeformationChart(); } - - // Handle backlight dock visibility - if (m_backlightDock) { - bool hasBacklight = state.rparams.backlight_correction != nullptr; - m_backlightDock->setVisible(hasBacklight && - m_backlightDock->widget() != nullptr); - } - updateRegistrationGroupVisibility(); if (m_sharpnessPanel) { @@ -4132,27 +3868,6 @@ void MainWindow::restoreWindowState() { restoreGeometry(settings.value("windowGeometry").toByteArray()); restoreState(settings.value("windowState").toByteArray()); restoreState(settings.value("windowState").toByteArray()); - - // Fix for docks showing up empty if restored as visible but content is in - // panel (not detached) - auto fixDockVisibility = [](QDockWidget *dock) { - if (dock && dock->widget() == nullptr) { - dock->hide(); - dock->setFloating(false); // Ensure it's not floating empty - } - }; - fixDockVisibility(m_mtfDock); - fixDockVisibility(m_tilesDock); - fixDockVisibility(m_colorTilesDock); - fixDockVisibility(m_correctedColorTilesDock); - - // Add missing chart docks - fixDockVisibility(m_spectraDock); - fixDockVisibility(m_deformationDock); - fixDockVisibility(m_lensDock); - fixDockVisibility(m_perspectiveDock); - fixDockVisibility(m_nonlinearDock); - fixDockVisibility(m_screenPreviewDock); } else { // Default size and position resize(1200, 800); diff --git a/src/qtgui/MainWindow.h b/src/qtgui/MainWindow.h index 31c1ac97..aad92714 100644 --- a/src/qtgui/MainWindow.h +++ b/src/qtgui/MainWindow.h @@ -207,10 +207,19 @@ class MainWindow : public QMainWindow { /** Return the document's primary image view. */ ImageWidget *primaryImageWidget() const { return m_imageWidget; } - /** Return the transient per-document progress controls shown while this - document is the active workspace document. */ + /** Return this document's transient progress presentation. Attached + workspaces host it globally regardless of the selected tab. */ QWidget *workspaceStatusWidget() const { return m_progressContainer; } + /** Remove/restore transient progress from this document's private bar. */ + QWidget *takeWorkspaceStatusWidget(); + void restoreWorkspaceStatusWidget(); + + /** Return whether transient progress has passed the display delay. */ + bool hasVisibleTransientProgress() const { + return m_transientProgressVisible; + } + /** Return this document's persistent user-visible progress rows. Attached documents keep this widget in the workspace global status area @@ -260,6 +269,8 @@ class MainWindow : public QMainWindow { void documentStateChanged(); /** Emitted when this document gains or loses dedicated progress rows. */ void userVisibleProgressVisibilityChanged(bool visible); + /** Emitted when delayed transient progress appears or disappears. */ + void transientProgressVisibilityChanged(bool visible); private slots: void onZoomIn(); @@ -591,15 +602,13 @@ private slots: QVBoxLayout *m_userVisibleProgressLayout = nullptr; QDockWidget *m_userVisibleProgressDock = nullptr; QWidget *m_transientProgressRow = nullptr; + bool m_transientProgressVisible = false; // Progress switcher UI (for multiple transient progresses) QLabel *m_progressCountLabel; QPushButton *m_prevProgressButton; QPushButton *m_nextProgressButton; - QDockWidget *m_sharpnessFinetuneImagesDock = nullptr; - AdaptiveSharpeningChart *m_adaptiveSharpeningChart = nullptr; - QDockWidget *m_adaptiveSharpeningDock = nullptr; QTimer *m_progressTimer; QTimer *m_recoveryTimer; // Auto-save timer for crash recovery @@ -635,6 +644,9 @@ private slots: /** Return focus from a disappearing long-task row to an image canvas. */ void releaseUserVisibleProgressFocus(QWidget *row); + /** Set delayed transient visibility and notify the workspace. */ + void setTransientProgressVisible(bool visible); + /** Synchronize visibility of the outer progress container. */ void updateProgressContainerVisibility(); @@ -663,24 +675,7 @@ private slots: std::vector m_panels; // Docks - QDockWidget *m_mtfDock; - QDockWidget *m_dotSpreadDock; - QDockWidget *m_spectraDock; - QDockWidget *m_tilesDock; - QDockWidget *m_colorTilesDock; - QDockWidget *m_correctedColorTilesDock; - QDockWidget *m_screenPreviewDock; - QDockWidget *m_deformationDock; - QDockWidget *m_lensDock; - QDockWidget *m_perspectiveDock; - QDockWidget *m_nonlinearDock; - QDockWidget *m_backlightDock; BacklightChartWidget *m_backlightChart; - QDockWidget *m_finetuneImagesDock; // Finetune diagnostic images dock (Geometry) - QDockWidget *m_gamutDock; // Gamut visualization dock - QDockWidget *m_hdCurveDock; // Added - QDockWidget *m_toneCurveDock; // Added - QDockWidget *m_correctedGamutDock; // Corrected gamut visualization dock // Current parameters file path QString m_currentImageFile; diff --git a/src/qtgui/ParameterPanel.cpp b/src/qtgui/ParameterPanel.cpp index bb242e91..1b598928 100644 --- a/src/qtgui/ParameterPanel.cpp +++ b/src/qtgui/ParameterPanel.cpp @@ -2,18 +2,275 @@ #include "../libcolorscreen/include/base.h" #include "SmartSpinBox.h" #include +#include +#include +#include #include #include #include #include #include #include +#include +#include +#include #include #include #include +#include #include +#include +#include #include +namespace { + +/** One uniform detachable section used by every parameter panel. + + The section, rather than MainWindow or a specialized view, owns the floating + dock. It therefore follows the panel into whichever QMainWindow currently + presents the inspector and can reattach its content without layout probing + or panel-specific callbacks. */ +class DetachableSection final : public QWidget { +public: + DetachableSection(const QString &title, QWidget *content, + std::function beforeDetach, + QWidget *parent = nullptr) + : QWidget(parent), m_title(title), m_content(content), + m_beforeDetach(std::move(beforeDetach)) { + setObjectName(QStringLiteral("DetachableSection")); + setProperty("detachableTitle", title); + + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(0, 0, 0, 0); + m_layout->setSpacing(0); + + auto *header = new QWidget(this); + auto *headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(0, 0, 0, 0); + + auto *label = new QLabel(title, header); + QFont font = label->font(); + font.setBold(true); + label->setFont(font); + headerLayout->addWidget(label); + headerLayout->addStretch(1); + + m_button = new QPushButton(QIcon::fromTheme("view-restore"), tr("Detach"), + header); + m_button->setObjectName(QStringLiteral("DetachableSectionButton")); + m_button->setProperty("detachableTitle", title); + m_button->setFlat(true); + m_button->setCursor(Qt::PointingHandCursor); + m_button->setMaximumHeight(24); + headerLayout->addWidget(m_button); + + m_layout->addWidget(header); + if (m_content) { + m_content->setProperty("detachableContentTitle", title); + m_layout->addWidget(m_content); + } + + connect(m_button, &QPushButton::clicked, this, [this]() { + if (m_dock) + reattach(); + else + detach(); + }); + } + + ~DetachableSection() override { reattach(false); } + +protected: + bool eventFilter(QObject *watched, QEvent *event) override { + if (watched == m_dock.data() && event && event->type() == QEvent::Close) { + event->ignore(); + reattach(); + return true; + } + return QWidget::eventFilter(watched, event); + } + + void showEvent(QShowEvent *event) override { + QWidget::showEvent(event); + // Inspectors move between the workspace, ordinary detached views, and + // specialized reference views. Keep an already detached dock with the + // top-level window that currently presents this section. + QTimer::singleShot(0, this, [this]() { migrateDockToCurrentHost(); }); + } + +private: + struct WidgetPresentation { + QPointer widget; + QSize minimumSize; + QSize maximumSize; + QSizePolicy sizePolicy; + Qt::Alignment alignment; + }; + + QMainWindow *currentHost() const { + return qobject_cast(window()); + } + + void snapshotPresentation() { + m_presentation.clear(); + if (!m_content) + return; + + QList widgets = m_content->findChildren(); + widgets.prepend(m_content); + for (QWidget *widget : widgets) { + WidgetPresentation state; + state.widget = widget; + state.minimumSize = widget->minimumSize(); + state.maximumSize = widget->maximumSize(); + state.sizePolicy = widget->sizePolicy(); + if (QWidget *parent = widget->parentWidget()) { + if (QLayout *layout = parent->layout()) { + const int index = layout->indexOf(widget); + if (index >= 0 && layout->itemAt(index)) + state.alignment = layout->itemAt(index)->alignment(); + } + } + m_presentation.push_back(state); + } + } + + void restorePresentation() { + for (const WidgetPresentation &state : std::as_const(m_presentation)) { + QWidget *widget = state.widget.data(); + if (!widget) + continue; + widget->setMinimumSize(state.minimumSize); + widget->setMaximumSize(state.maximumSize); + widget->setSizePolicy(state.sizePolicy); + if (QWidget *parent = widget->parentWidget()) { + if (QLayout *layout = parent->layout()) + layout->setAlignment(widget, state.alignment); + } + } + m_presentation.clear(); + } + + void detach() { + if (!m_content || m_dock) + return; + + QMainWindow *host = currentHost(); + if (!host) + return; + + snapshotPresentation(); + if (m_beforeDetach) + m_beforeDetach(); + + static quint64 serial = 0; + QString key = m_title; + key.remove(QRegularExpression(QStringLiteral("[^A-Za-z0-9]+"))); + if (key.isEmpty()) + key = QStringLiteral("Panel"); + + auto *dock = new QDockWidget(m_title, host); + dock->setObjectName(QStringLiteral("DetachedPanelDock_%1_%2") + .arg(key) + .arg(++serial)); + dock->setProperty("detachablePanel", true); + dock->setProperty("detachableTitle", m_title); + dock->setAllowedAreas(Qt::AllDockWidgetAreas); + dock->setFeatures(QDockWidget::DockWidgetClosable | + QDockWidget::DockWidgetMovable | + QDockWidget::DockWidgetFloatable); + dock->installEventFilter(this); + connect(dock, &QObject::destroyed, this, [this]() { + m_dock.clear(); + if (m_content && m_content->parentWidget() != this) { + m_content->setParent(this); + m_layout->addWidget(m_content); + m_content->show(); + restorePresentation(); + } + updateButton(false); + }); + + m_dock = dock; + host->addDockWidget(Qt::RightDockWidgetArea, dock); + dock->setWidget(m_content); + dock->setFloating(true); + if (m_content->sizeHint().isValid()) + dock->resize(m_content->sizeHint().expandedTo(QSize(320, 220))); + dock->show(); + dock->raise(); + updateButton(true); + } + + void reattach(bool restoreSizing = true) { + QDockWidget *dock = m_dock.data(); + if (!dock) { + if (m_content && m_content->parentWidget() != this) { + m_content->setParent(this); + m_layout->addWidget(m_content); + m_content->show(); + } + if (restoreSizing) + restorePresentation(); + updateButton(false); + return; + } + + dock->removeEventFilter(this); + if (QMainWindow *host = qobject_cast(dock->parentWidget())) + host->removeDockWidget(dock); + + if (m_content) { + m_content->setParent(this); + m_layout->addWidget(m_content); + m_content->show(); + } + dock->setWidget(nullptr); + m_dock.clear(); + dock->hide(); + dock->deleteLater(); + + if (restoreSizing) + restorePresentation(); + updateButton(false); + } + + void migrateDockToCurrentHost() { + QDockWidget *dock = m_dock.data(); + QMainWindow *host = currentHost(); + if (!dock || !host || dock->parentWidget() == host) + return; + + if (QMainWindow *oldHost = + qobject_cast(dock->parentWidget())) + oldHost->removeDockWidget(dock); + dock->setParent(host); + host->addDockWidget(Qt::RightDockWidgetArea, dock); + dock->setFloating(true); + dock->show(); + dock->raise(); + } + + void updateButton(bool detached) { + if (!m_button) + return; + m_button->setText(detached ? tr("Reattach") : tr("Detach")); + m_button->setToolTip(detached ? tr("Return this panel to the inspector") + : tr("Show this panel in a floating dock")); + } + + QString m_title; + QPointer m_content; + std::function m_beforeDetach; + QVBoxLayout *m_layout = nullptr; + QPushButton *m_button = nullptr; + QPointer m_dock; + std::vector m_presentation; +}; + +} // namespace + ParameterPanel::ParameterPanel(StateGetter stateGetter, StateSetter stateSetter, ImageGetter imageGetter, QWidget *parent, bool useScrollArea) @@ -944,56 +1201,10 @@ QToolButton *ParameterPanel::addSeparator(const QString &title) { } QWidget * -ParameterPanel::createDetachableSection(const QString &title, QWidget *content, - std::function onDetach) { - QWidget *container = new QWidget(); - QVBoxLayout *layout = new QVBoxLayout(container); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - // Header - QWidget *header = new QWidget(); - QHBoxLayout *headerLayout = new QHBoxLayout(header); - headerLayout->setContentsMargins(0, 0, 0, 0); - - QLabel *label = new QLabel(title); - QFont f = label->font(); - f.setBold(true); - label->setFont(f); - headerLayout->addWidget(label); - - headerLayout->addStretch(1); - - QPushButton *detachBtn = - new QPushButton(QIcon::fromTheme("view-restore"), "Detach"); - detachBtn->setFlat(true); - detachBtn->setCursor(Qt::PointingHandCursor); - detachBtn->setMaximumHeight(24); - - headerLayout->addWidget(detachBtn); - - layout->addWidget(header); - layout->addWidget(content); - - connect(detachBtn, &QPushButton::clicked, this, - [onDetach, container, title, header]() { - if (onDetach) - onDetach(); - - // Remove content from layout (it is reparented by Dock anyway) - // Add placeholder - if (container->layout()->count() > 1) { // Header + Content - container->layout()->takeAt(1); // Remove content item - } - - QWidget *placeholder = new QWidget(); - placeholder->setVisible(false); - container->layout()->addWidget(placeholder); - - header->hide(); - }); - - return container; +ParameterPanel::createDetachableSection( + const QString &title, QWidget *content, + std::function beforeDetach) { + return new DetachableSection(title, content, std::move(beforeDetach)); } diff --git a/src/qtgui/ParameterPanel.h b/src/qtgui/ParameterPanel.h index 6a3648c0..b6111cef 100644 --- a/src/qtgui/ParameterPanel.h +++ b/src/qtgui/ParameterPanel.h @@ -156,9 +156,14 @@ class ParameterPanel : public QWidget { QToolButton *addSeparator(const QString &title); - // Helpers to create detachable sections - QWidget *createDetachableSection(const QString &title, QWidget *content, - std::function onDetach); + /** Wrap CONTENT in the standard detachable-panel presentation. + The returned section owns the floating QDockWidget lifecycle and + always reattaches CONTENT when the dock closes or its host changes. + BEFOREDETACH is retained for panel-specific sizing adjustments; the + section snapshots and restores widget constraints automatically. */ + QWidget *createDetachableSection( + const QString &title, QWidget *content, + std::function beforeDetach = {}); // Ends the current group (if any) so subsequent items are added to the main form void endGroup(); diff --git a/src/qtgui/TilePreviewPanel.cpp b/src/qtgui/TilePreviewPanel.cpp index 46ce335a..fce884e1 100644 --- a/src/qtgui/TilePreviewPanel.cpp +++ b/src/qtgui/TilePreviewPanel.cpp @@ -91,7 +91,6 @@ TilePreviewPanel::TilePreviewPanel(StateGetter stateGetter, m_updateTimer = new QTimer(this); m_updateTimer->setSingleShot(true); m_updateTimer->setInterval(30); - m_updateTimer->setInterval(30); // Debounce -> Request Render in Queue connect(m_updateTimer, &QTimer::timeout, this, [this](){ // Capture state at the end of debounce (request time) diff --git a/src/qtgui/WorkspaceWindow.cpp b/src/qtgui/WorkspaceWindow.cpp index 4c19c8db..fad87baa 100644 --- a/src/qtgui/WorkspaceWindow.cpp +++ b/src/qtgui/WorkspaceWindow.cpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include #include #include @@ -22,6 +24,8 @@ #include #include #include +#include +#include #include namespace { @@ -116,16 +120,49 @@ WorkspaceWindow::WorkspaceWindow(QWidget *parent) : QMainWindow(parent) { statusBar()->setObjectName(QStringLiteral("WorkspaceStatusBar")); - // The ordinary status bar is always a single bottom line. Only the active - // document's transient progress may occupy it. Dedicated long-running tasks - // live in a frameless bottom dock above the status bar, so rapid transient - // progress cannot repeatedly change the window's bottom-line height. + // The status bar is workspace-global. Every attached logical document + // contributes one transient-progress page, and a small outer switcher + // selects among concurrently working documents without following tabs. m_workspaceProgressArea = new QWidget(statusBar()); m_workspaceProgressArea->setObjectName( QStringLiteral("WorkspaceProgressArea")); - m_workspaceProgressLayout = new QVBoxLayout(m_workspaceProgressArea); + m_workspaceProgressLayout = new QHBoxLayout(m_workspaceProgressArea); m_workspaceProgressLayout->setContentsMargins(0, 0, 0, 0); - m_workspaceProgressLayout->setSpacing(0); + m_workspaceProgressLayout->setSpacing(6); + + m_workspaceProgressStack = new QStackedWidget(m_workspaceProgressArea); + m_workspaceProgressStack->setObjectName( + QStringLiteral("WorkspaceTransientProgressStack")); + m_workspaceProgressLayout->addWidget(m_workspaceProgressStack, 1); + + m_workspaceProgressDocumentLabel = + new QLabel(m_workspaceProgressArea); + m_workspaceProgressDocumentLabel->setObjectName( + QStringLiteral("WorkspaceProgressDocumentLabel")); + m_workspaceProgressLayout->addWidget(m_workspaceProgressDocumentLabel); + + m_workspaceProgressPreviousButton = + new QToolButton(m_workspaceProgressArea); + m_workspaceProgressPreviousButton->setObjectName( + QStringLiteral("WorkspaceProgressPreviousButton")); + m_workspaceProgressPreviousButton->setText(QStringLiteral("<")); + m_workspaceProgressPreviousButton->setToolTip( + tr("Previous document progress")); + connect(m_workspaceProgressPreviousButton, &QToolButton::clicked, this, + [this]() { cycleWorkspaceProgress(-1); }); + m_workspaceProgressLayout->addWidget( + m_workspaceProgressPreviousButton); + + m_workspaceProgressNextButton = new QToolButton(m_workspaceProgressArea); + m_workspaceProgressNextButton->setObjectName( + QStringLiteral("WorkspaceProgressNextButton")); + m_workspaceProgressNextButton->setText(QStringLiteral(">")); + m_workspaceProgressNextButton->setToolTip( + tr("Next document progress")); + connect(m_workspaceProgressNextButton, &QToolButton::clicked, this, + [this]() { cycleWorkspaceProgress(1); }); + m_workspaceProgressLayout->addWidget(m_workspaceProgressNextButton); + m_workspaceProgressArea->hide(); statusBar()->addPermanentWidget(m_workspaceProgressArea, 1); m_userVisibleProgressStack = new QWidget(); @@ -183,9 +220,7 @@ void WorkspaceWindow::addDocument(MainWindow *document) { inspector->hide(); } - attachUserVisibleProgress(document); - connect(document, &MainWindow::userVisibleProgressVisibilityChanged, this, - [this](bool) { updateUserVisibleProgressDockVisibility(); }); + attachDocumentProgress(document); document->setWindowFlags(Qt::Widget); auto *subWindow = new DocumentSubWindow(document); @@ -255,6 +290,7 @@ void WorkspaceWindow::addView(ImageViewWindow *view) { m_inspectorStack->addWidget(inspector); inspector->hide(); } + attachDocumentProgress(view->sourceDocument()); view->setAttribute(Qt::WA_DeleteOnClose, false); view->setWindowFlags(Qt::Widget); @@ -275,8 +311,10 @@ void WorkspaceWindow::addView(ImageViewWindow *view) { setWindowTitle(title + tr(" — Color-Screen")); configureTabBar(); }); - connect(subWindow, &QObject::destroyed, this, [this]() { - QTimer::singleShot(0, this, [this]() { + QPointer guardedSource(view->sourceDocument()); + connect(subWindow, &QObject::destroyed, this, [this, guardedSource]() { + QTimer::singleShot(0, this, [this, guardedSource]() { + detachDocumentProgressIfUnused(guardedSource); onSubWindowActivated(m_mdiArea->currentSubWindow()); configureTabBar(); scheduleCloseIfEmpty(); @@ -673,6 +711,103 @@ void WorkspaceWindow::scheduleCloseIfEmpty() { }); } +/** Return whether DOCUMENT still has a presentation in this workspace. */ +bool WorkspaceWindow::hasAttachedPresentation(MainWindow *document) const { + if (!document || !m_mdiArea) + return false; + for (QMdiSubWindow *subWindow : m_mdiArea->subWindowList()) { + if (documentForSubWindow(subWindow) == document) + return true; + if (ImageViewWindow *view = viewForSubWindow(subWindow)) { + if (view->sourceDocument() == document) + return true; + } + } + return false; +} + +/** Permanently attach one logical document's progress to the workspace shell. */ +void WorkspaceWindow::attachDocumentProgress(MainWindow *document) { + if (!document || !m_workspaceProgressStack) + return; + + bool alreadyAttached = false; + for (const QPointer &candidate : std::as_const(m_progressDocuments)) { + if (candidate == document) { + alreadyAttached = true; + break; + } + } + + if (!alreadyAttached) { + QWidget *progress = document->takeWorkspaceStatusWidget(); + if (progress) { + progress->setParent(m_workspaceProgressStack); + m_workspaceProgressStack->addWidget(progress); + m_progressDocuments.append(document); + const int stableHeight = qMax( + statusBar()->minimumHeight(), + qMax(progress->minimumHeight(), progress->sizeHint().height())); + statusBar()->setMinimumHeight(stableHeight); + } + + QPointer guardedDocument(document); + connect(document, &MainWindow::transientProgressVisibilityChanged, this, + [this, guardedDocument](bool visible) { + if (visible) + m_displayedProgressDocument = guardedDocument; + updateWorkspaceProgressPresentation(); + }); + connect(document, &MainWindow::userVisibleProgressVisibilityChanged, this, + [this](bool) { updateUserVisibleProgressDockVisibility(); }); + connect(document, &QWidget::windowTitleChanged, this, + [this, guardedDocument](const QString &) { + if (guardedDocument == m_displayedProgressDocument) + updateWorkspaceProgressPresentation(); + }); + connect(document, &QObject::destroyed, this, [this]() { + for (auto it = m_progressDocuments.begin(); + it != m_progressDocuments.end();) { + if (it->isNull()) + it = m_progressDocuments.erase(it); + else + ++it; + } + if (!m_displayedProgressDocument) + m_displayedProgressDocument.clear(); + updateWorkspaceProgressPresentation(); + updateUserVisibleProgressDockVisibility(); + }); + } + + attachUserVisibleProgress(document); + if (document->hasVisibleTransientProgress()) + m_displayedProgressDocument = document; + updateWorkspaceProgressPresentation(); +} + +/** Return progress to DOCUMENT only after its final attached presentation leaves. */ +void WorkspaceWindow::detachDocumentProgressIfUnused(MainWindow *document) { + if (!document || hasAttachedPresentation(document)) + return; + + detachUserVisibleProgress(document); + QWidget *progress = document->workspaceStatusWidget(); + if (progress && m_workspaceProgressStack->indexOf(progress) >= 0) + m_workspaceProgressStack->removeWidget(progress); + document->restoreWorkspaceStatusWidget(); + + for (auto it = m_progressDocuments.begin(); it != m_progressDocuments.end();) { + if (it->isNull() || it->data() == document) + it = m_progressDocuments.erase(it); + else + ++it; + } + if (m_displayedProgressDocument == document) + m_displayedProgressDocument.clear(); + updateWorkspaceProgressPresentation(); +} + /** Keep DOCUMENT's persistent long-running task rows globally visible. */ void WorkspaceWindow::attachUserVisibleProgress(MainWindow *document) { if (!document || !m_userVisibleProgressLayout) @@ -721,6 +856,66 @@ void WorkspaceWindow::updateUserVisibleProgressDockVisibility() { m_userVisibleProgressDock->setVisible(hasVisibleRows); } +/** Show one working document in the shared one-line status presentation. */ +void WorkspaceWindow::updateWorkspaceProgressPresentation() { + if (!m_workspaceProgressArea || !m_workspaceProgressStack) + return; + + QList visible; + for (auto it = m_progressDocuments.begin(); it != m_progressDocuments.end();) { + if (it->isNull()) { + it = m_progressDocuments.erase(it); + continue; + } + if ((*it)->hasVisibleTransientProgress()) + visible.append(it->data()); + ++it; + } + + if (visible.isEmpty()) { + m_displayedProgressDocument.clear(); + m_workspaceProgressDocumentLabel->clear(); + m_workspaceProgressArea->hide(); + return; + } + + if (!visible.contains(m_displayedProgressDocument.data())) + m_displayedProgressDocument = visible.constLast(); + + MainWindow *document = m_displayedProgressDocument.data(); + QWidget *progress = document ? document->workspaceStatusWidget() : nullptr; + if (progress && m_workspaceProgressStack->indexOf(progress) >= 0) + m_workspaceProgressStack->setCurrentWidget(progress); + + const bool multiple = visible.size() > 1; + m_workspaceProgressDocumentLabel->setText( + document ? document->documentDisplayName() : QString()); + m_workspaceProgressDocumentLabel->setVisible(multiple); + m_workspaceProgressPreviousButton->setVisible(multiple); + m_workspaceProgressNextButton->setVisible(multiple); + m_workspaceProgressArea->show(); +} + +/** Cycle among documents with visible transient progress. */ +void WorkspaceWindow::cycleWorkspaceProgress(int offset) { + QList visible; + for (const QPointer &document : std::as_const(m_progressDocuments)) { + if (document && document->hasVisibleTransientProgress()) + visible.append(document.data()); + } + if (visible.isEmpty()) + return; + + int index = visible.indexOf(m_displayedProgressDocument.data()); + if (index < 0) + index = 0; + index = (index + offset) % visible.size(); + if (index < 0) + index += visible.size(); + m_displayedProgressDocument = visible[index]; + updateWorkspaceProgressPresentation(); +} + /** Return keyboard focus from CONTROL in the global task strip to the current image without allowing focus fallback to select another MDI child. */ bool WorkspaceWindow::restoreFocusFromTaskControl(QWidget *control) { @@ -799,15 +994,6 @@ void WorkspaceWindow::installDocumentChrome(MainWindow *document) { installDocumentInspector(document, document->primaryImageWidget()); - if (QWidget *statusWidget = document->workspaceStatusWidget()) { - if (statusWidget->parentWidget() != m_workspaceProgressArea) { - const bool explicitlyHidden = statusWidget->isHidden(); - document->standaloneStatusBar()->removeWidget(statusWidget); - statusWidget->setParent(m_workspaceProgressArea); - m_workspaceProgressLayout->addWidget(statusWidget); - statusWidget->setVisible(!explicitlyHidden); - } - } document->refreshWindowMenu(); setWindowTitle(document->documentDisplayName() + tr(" — Color-Screen")); @@ -828,15 +1014,7 @@ void WorkspaceWindow::releaseDocumentChrome(MainWindow *document, toolbar->setVisible(showInWindow); } - if (QWidget *statusWidget = document->workspaceStatusWidget()) { - if (statusWidget->parentWidget() == m_workspaceProgressArea) { - const bool explicitlyHidden = statusWidget->isHidden(); - m_workspaceProgressLayout->removeWidget(statusWidget); - document->standaloneStatusBar()->addPermanentWidget(statusWidget, 1); - statusWidget->setVisible(!explicitlyHidden); - } - document->standaloneStatusBar()->setVisible(showInWindow); - } + document->standaloneStatusBar()->setVisible(showInWindow); if (showInWindow) document->setWorkspaceStatusBar(nullptr); @@ -968,7 +1146,6 @@ void WorkspaceWindow::takeDocumentFromWorkspace(MainWindow *document) { return; releaseDocumentChrome(document, true); - detachUserVisibleProgress(document); if (QWidget *inspector = document->workspaceInspectorWidget()) { m_inspectorStack->removeWidget(inspector); document->takeWorkspaceInspector(); @@ -980,6 +1157,7 @@ void WorkspaceWindow::takeDocumentFromWorkspace(MainWindow *document) { // here would delete it immediately and make deleteLater() unsafe. m_mdiArea->removeSubWindow(document); subWindow->deleteLater(); + detachDocumentProgressIfUnused(document); document->setParent(nullptr); document->setWindowFlags(Qt::Window); @@ -987,6 +1165,7 @@ void WorkspaceWindow::takeDocumentFromWorkspace(MainWindow *document) { /** Remove secondary VIEW's MDI wrapper while keeping VIEW alive. */ void WorkspaceWindow::takeViewFromWorkspace(ImageViewWindow *view) { + MainWindow *sourceDocument = view ? view->sourceDocument() : nullptr; QMdiSubWindow *subWindow = subWindowForView(view); if (!subWindow) return; @@ -1007,6 +1186,7 @@ void WorkspaceWindow::takeViewFromWorkspace(ImageViewWindow *view) { view->hide(); m_mdiArea->removeSubWindow(view); subWindow->deleteLater(); + detachDocumentProgressIfUnused(sourceDocument); view->setParent(nullptr); view->setWindowFlags(Qt::Window); diff --git a/src/qtgui/WorkspaceWindow.h b/src/qtgui/WorkspaceWindow.h index 76b40d1b..91b47f4a 100644 --- a/src/qtgui/WorkspaceWindow.h +++ b/src/qtgui/WorkspaceWindow.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -14,7 +15,10 @@ class QEvent; class ImageWidget; class QMdiArea; class QMdiSubWindow; +class QHBoxLayout; +class QLabel; class QStackedWidget; +class QToolButton; class QTabBar; class QVBoxLayout; class QWidget; @@ -93,6 +97,11 @@ class WorkspaceWindow final : public QMainWindow { active image presentation without changing the active MDI child. */ bool restoreFocusFromTaskControl(QWidget *control); + /** Return the document whose transient progress the shell displays. */ + MainWindow *displayedProgressDocument() const { + return m_displayedProgressDocument.data(); + } + /** Restore/save only the outer workspace geometry. */ void restoreWorkspaceGeometry(); void saveWorkspaceGeometry() const; @@ -107,12 +116,10 @@ class WorkspaceWindow final : public QMainWindow { /** Implement drag-out tab detachment while preserving ordinary tab moves. */ bool eventFilter(QObject *watched, QEvent *event) override; - /** Freeze the shared status line at its initial document-control height. + /** Keep the one shared status line at a stable one-row height. - The first show occurs after the active document's transient progress - widget has been installed. Secondary views do not own such a widget, so - without this floor Qt would shrink the same shared QStatusBar when one of - those tabs becomes active. */ + Transient progress pages are permanently owned by the workspace and tab + activation never adds or removes them. */ void showEvent(QShowEvent *event) override { QMainWindow::showEvent(event); QStatusBar *bar = QMainWindow::statusBar(); @@ -148,7 +155,16 @@ class WorkspaceWindow final : public QMainWindow { /** Close the shell after its final hosted presentation has left. */ void scheduleCloseIfEmpty(); - /** Keep DOCUMENT's user-visible long tasks in the global status area. */ + /** Attach both transient and long-running progress for DOCUMENT. */ + void attachDocumentProgress(MainWindow *document); + + /** Detach progress only after DOCUMENT has no workspace presentation. */ + void detachDocumentProgressIfUnused(MainWindow *document); + + /** Return whether DOCUMENT still has a primary or secondary MDI view. */ + bool hasAttachedPresentation(MainWindow *document) const; + + /** Keep DOCUMENT's user-visible long tasks in the global task strip. */ void attachUserVisibleProgress(MainWindow *document); /** Return DOCUMENT's user-visible rows before detaching it. */ @@ -157,11 +173,14 @@ class WorkspaceWindow final : public QMainWindow { /** Show the task-progress dock iff any attached document has visible rows. */ void updateUserVisibleProgressDockVisibility(); + /** Select one visible transient document without following tab changes. */ + void updateWorkspaceProgressPresentation(); + void cycleWorkspaceProgress(int offset); + /** Put DOCUMENT's shared inspector in the workspace and target IMAGEWIDGET. */ void installDocumentInspector(MainWindow *document, ImageWidget *imageWidget); - /** Show DOCUMENT's menus, toolbar, inspector, and transient progress as the - active workspace chrome. */ + /** Show DOCUMENT's menus, toolbar, and inspector as active chrome. */ void installDocumentChrome(MainWindow *document); /** Return DOCUMENT's toolbar/menu visibility to its own window. @@ -195,7 +214,13 @@ class WorkspaceWindow final : public QMainWindow { QDockWidget *m_inspectorDock = nullptr; QStackedWidget *m_inspectorStack = nullptr; QWidget *m_workspaceProgressArea = nullptr; - QVBoxLayout *m_workspaceProgressLayout = nullptr; + QHBoxLayout *m_workspaceProgressLayout = nullptr; + QStackedWidget *m_workspaceProgressStack = nullptr; + QLabel *m_workspaceProgressDocumentLabel = nullptr; + QToolButton *m_workspaceProgressPreviousButton = nullptr; + QToolButton *m_workspaceProgressNextButton = nullptr; + QList> m_progressDocuments; + QPointer m_displayedProgressDocument; QWidget *m_userVisibleProgressStack = nullptr; QVBoxLayout *m_userVisibleProgressLayout = nullptr; QDockWidget *m_userVisibleProgressDock = nullptr; diff --git a/src/qtgui/main.cpp b/src/qtgui/main.cpp index 1e6c7080..8412c69c 100644 --- a/src/qtgui/main.cpp +++ b/src/qtgui/main.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -539,7 +540,8 @@ int main(int argc, char *argv[]) { if (!candidate || !workspace->containsDocument(candidate)) continue; if (candidate->statusBar() != workspaceStatus || - candidate->standaloneStatusBar()->isVisible()) { + candidate->standaloneStatusBar()->isVisible() || + !workspaceStatus->isAncestorOf(candidate->workspaceStatusWidget())) { qCritical() << "Attached document has a private status bar"; app.exit(11); return; @@ -662,24 +664,33 @@ int main(int argc, char *argv[]) { return; } - // A short-lived transient task may occupy the bottom status line but must - // not reparent dedicated rows or alter the height of that line. + // Transient work belongs to the workspace, not the selected tab. Start + // work in the second document while the first remains active and require + // the shared status line to present it after the normal display delay. auto transientProgress = std::make_shared(); - transientProgress->set_task("transient smoke task", 100); + transientProgress->set_task("inactive document transient smoke task", 100); transientProgress->set_progress(10); - cancelDocument->addProgress(transientProgress); + stopDocument->addProgress(transientProgress); + QThread::msleep(350); QCoreApplication::processEvents(); - if (workspaceStatus->height() != statusHeight || + QWidget *workspaceProgress = workspace->findChild( + QStringLiteral("WorkspaceProgressArea")); + if (!workspaceProgress || workspaceProgress->isHidden() || + workspace->currentDocument() != cancelDocument || + workspace->displayedProgressDocument() != stopDocument || + !workspaceProgress->isAncestorOf(stopDocument->workspaceStatusWidget()) || + workspaceStatus->height() != statusHeight || !taskStack->isAncestorOf(cancelContainer) || !taskStack->isAncestorOf(stopContainer)) { - qCritical() << "Transient progress disturbed the dedicated task strip"; + qCritical() << "Inactive document transient progress was not presented globally"; app.exit(13); return; } - cancelDocument->removeProgress(transientProgress); + stopDocument->removeProgress(transientProgress); QCoreApplication::processEvents(); - if (workspaceStatus->height() != statusHeight) { - qCritical() << "Transient progress changed status-bar height on exit"; + if (workspace->currentDocument() != cancelDocument || + workspaceStatus->height() != statusHeight) { + qCritical() << "Transient progress exit changed tab or status-bar height"; app.exit(13); return; } @@ -1381,23 +1392,97 @@ int main(int argc, char *argv[]) { } detachMtf->click(); QCoreApplication::processEvents(); - QDockWidget *mtfDock = reference->findChild( - QStringLiteral("SlantedEdgeMTFChartDock")); + QDockWidget *mtfDock = nullptr; + for (QDockWidget *candidate : workspace->findChildren()) { + if (candidate && candidate->property("detachablePanel").toBool() && + candidate->property("detachableTitle").toString() == + QStringLiteral("MTF Chart") && + candidate->isAncestorOf(mtfChart)) { + mtfDock = candidate; + break; + } + } if (!mtfDock || !mtfDock->isVisible() || !mtfDock->isFloating() || !mtfDock->widget() || !mtfDock->isAncestorOf(mtfChart)) { qCritical() << "Reference MTF chart disappeared instead of detaching"; app.exit(15); return; } + QPointer guardedMtfDock(mtfDock); mtfDock->close(); QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); QCoreApplication::processEvents(); - if (mtfDock->widget() || - !reference->workspaceInspectorWidget()->isAncestorOf(mtfChart)) { + if (guardedMtfDock && guardedMtfDock->widget()) { + qCritical() << "Generic MTF dock retained its content after close"; + app.exit(15); + return; + } + if (!reference->workspaceInspectorWidget()->isAncestorOf(mtfChart) || + detachMtf->text() != QStringLiteral("Detach")) { qCritical() << "Reference MTF chart did not reattach after dock close"; app.exit(15); return; } + + // Exercise the same implementation in two unrelated document panels. + workspace->activateDocument(source); + QCoreApplication::processEvents(); + auto exerciseDetachable = [workspace, source](const QString &title) { + QWidget *inspector = source->workspaceInspectorWidget(); + QWidget *section = nullptr; + for (QWidget *candidate : inspector->findChildren()) { + if (candidate->objectName() == QStringLiteral("DetachableSection") && + candidate->property("detachableTitle").toString() == title) { + section = candidate; + break; + } + } + QPushButton *button = section + ? section->findChild( + QStringLiteral("DetachableSectionButton")) + : nullptr; + QWidget *content = nullptr; + if (section) { + for (QWidget *candidate : section->findChildren()) { + if (candidate->property("detachableContentTitle").toString() == + title) { + content = candidate; + break; + } + } + } + if (!section || !button || !content) + return false; + button->click(); + QCoreApplication::processEvents(); + QDockWidget *dock = nullptr; + for (QDockWidget *candidate : workspace->findChildren()) { + if (candidate->property("detachablePanel").toBool() && + candidate->property("detachableTitle").toString() == title && + candidate->isAncestorOf(content)) { + dock = candidate; + break; + } + } + if (!dock || !dock->isVisible() || !dock->isFloating() || + button->text() != QStringLiteral("Reattach")) + return false; + QPointer guardedDock(dock); + dock->close(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + QCoreApplication::processEvents(); + return (!guardedDock || !guardedDock->widget()) && + inspector->isAncestorOf(content) && + button->text() == QStringLiteral("Detach"); + }; + if (!exerciseDetachable(QStringLiteral("H&D Curve")) || + !exerciseDetachable(QStringLiteral("Backlight"))) { + qCritical() << "Unrelated panels do not share the generic detach lifecycle"; + app.exit(15); + return; + } + workspace->activateView(reference); + QCoreApplication::processEvents(); workspace->showTabbedDocuments(); workspace->activateView(reference); QCoreApplication::processEvents(); From 134e8622b4bf6813d96d996a4e98066bab10fc77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hubi=C4=8Dka?= <46065755+janhubicka@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:24:12 +0200 Subject: [PATCH 2/8] qtgui: fix reference detachment and view File menu Keep the unified ParameterPanel detachable-section lifecycle while allowing specialized inspectors to pin their logical QMainWindow host. Pin slanted-edge Sharpness diagnostics to the reference ImageViewWindow so tiled reference MTF detachment remains stable. Mirror the source document File menu in secondary views while substituting Close View for Close Window, restoring Open, Save Parameters, Render, and Exit without duplicating document state. Extend GUI smoke coverage for File-menu parity and reference-owned generic MTF docks, and document the specialized-host rule. --- .agents/qtgui.md | 11 ++++++----- src/qtgui/ImageViewWindow.cpp | 30 +++++++++++++++++++++++++++++- src/qtgui/ParameterPanel.cpp | 23 ++++++++++++++++++++--- src/qtgui/ParameterPanel.h | 7 +++++++ src/qtgui/main.cpp | 27 ++++++++++++++++++++++++++- 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/.agents/qtgui.md b/.agents/qtgui.md index 5bd7e122..3e43a47b 100644 --- a/.agents/qtgui.md +++ b/.agents/qtgui.md @@ -140,18 +140,19 @@ limited to **Original digital capture** and **Image layer**. Measurements are applied through the source document's undoable parameter path. Every panel section created by `ParameterPanel::createDetachableSection()` owns its own standard floating-dock lifecycle. This is the only detach implementation: document windows, ordinary views, and specialized reference views do not create -parallel chart docks or probe nested layouts during reattachment. A detached +parallel chart docks or probe nested layouts during reattachment. By default a detached section follows the top-level window currently presenting its inspector and always -returns its content when closed. Sharpness diagnostics in a slanted-edge reference -therefore remain presentation-owned by that reference inspector without any -special-case dock wiring. **Reload and demosaic** reloads both the source scan and every associated +returns its content when closed. Specialized inspectors may pin that same generic +dock lifecycle to their logical presentation; slanted-edge Sharpness sections use +the reference `ImageViewWindow` as that host. This keeps reference diagnostics +presentation-owned without reintroducing panel-specific dock wiring. **Reload and demosaic** reloads both the source scan and every associated slanted-edge reference from its own filename using the current demosaic mode. **Window → New View** creates another MDI view of the same document. Ordinary views present the same complete Navigation + parameter-panel inspector as the primary view; the document owns one inspector instance and the active ordinary view merely presents it, so panel state, undo routing, and detached diagnostic -widgets are never duplicated. Ordinary views also expose the document's Edit +widgets are never duplicated. Ordinary views also expose the document's complete File commands, Edit and Registration menus and the same canvas-tool toolbar actions as the primary presentation; render mode, Color/IR, and coordinate controls remain view-local. Navigation and panel tools that act on an image (crop/area selection, distance diff --git a/src/qtgui/ImageViewWindow.cpp b/src/qtgui/ImageViewWindow.cpp index d220f472..c7d13f84 100644 --- a/src/qtgui/ImageViewWindow.cpp +++ b/src/qtgui/ImageViewWindow.cpp @@ -294,7 +294,7 @@ void ImageViewWindow::setupUi() { m_document->appendOrdinaryViewToolActions(m_toolbar); QMenu *fileMenu = menuBar()->addMenu(tr("&File")); - QAction *closeView = fileMenu->addAction(tr("&Close View")); + QAction *closeView = new QAction(tr("&Close View"), this); closeView->setShortcut(QKeySequence::Close); connect(closeView, &QAction::triggered, this, [this]() { if (auto *application = @@ -304,6 +304,33 @@ void ImageViewWindow::setupUi() { close(); }); + QMenu *documentFileMenu = nullptr; + if (m_document) { + for (QAction *menuAction : m_document->menuBar()->actions()) { + if (menuAction && QString(menuAction->text()).remove('&') == + QStringLiteral("File")) { + documentFileMenu = menuAction->menu(); + break; + } + } + } + bool addedCloseView = false; + if (documentFileMenu) { + for (QAction *action : documentFileMenu->actions()) { + if (!action) + continue; + if (QString(action->text()).remove('&') == + QStringLiteral("Close Window")) { + fileMenu->addAction(closeView); + addedCloseView = true; + } else { + fileMenu->addAction(action); + } + } + } + if (!addedCloseView) + fileMenu->addAction(closeView); + if (!m_slantedEdgeReference && m_document) if (QAction *edit = m_document->ordinaryViewEditMenuAction()) menuBar()->addAction(edit); @@ -411,6 +438,7 @@ void ImageViewWindow::setupReferenceInspector() { m_document->applySharedDocumentState(state, description); }, [this]() { return m_scan; }, m_referenceTabs); + m_sharpnessPanel->setDetachableHost(this); m_referenceTabs->addTab(m_sharpnessPanel, tr("Sharpness")); connect(m_sharpnessPanel, diff --git a/src/qtgui/ParameterPanel.cpp b/src/qtgui/ParameterPanel.cpp index 1b598928..a76df35e 100644 --- a/src/qtgui/ParameterPanel.cpp +++ b/src/qtgui/ParameterPanel.cpp @@ -34,10 +34,10 @@ namespace { class DetachableSection final : public QWidget { public: DetachableSection(const QString &title, QWidget *content, - std::function beforeDetach, + std::function beforeDetach, QMainWindow *host, QWidget *parent = nullptr) : QWidget(parent), m_title(title), m_content(content), - m_beforeDetach(std::move(beforeDetach)) { + m_beforeDetach(std::move(beforeDetach)), m_pinnedHost(host) { setObjectName(QStringLiteral("DetachableSection")); setProperty("detachableTitle", title); @@ -81,6 +81,12 @@ class DetachableSection final : public QWidget { ~DetachableSection() override { reattach(false); } + /** Pin this section to HOST, or resume following its containing window. */ + void setHost(QMainWindow *host) { + m_pinnedHost = host; + migrateDockToCurrentHost(); + } + protected: bool eventFilter(QObject *watched, QEvent *event) override { if (watched == m_dock.data() && event && event->type() == QEvent::Close) { @@ -109,6 +115,8 @@ class DetachableSection final : public QWidget { }; QMainWindow *currentHost() const { + if (m_pinnedHost) + return m_pinnedHost.data(); return qobject_cast(window()); } @@ -263,6 +271,7 @@ class DetachableSection final : public QWidget { QString m_title; QPointer m_content; std::function m_beforeDetach; + QPointer m_pinnedHost; QVBoxLayout *m_layout = nullptr; QPushButton *m_button = nullptr; QPointer m_dock; @@ -302,6 +311,13 @@ ParameterPanel::ParameterPanel(StateGetter stateGetter, StateSetter stateSetter, ParameterPanel::~ParameterPanel() = default; +/** Override the generic dynamic dock host for specialized panel owners. */ +void ParameterPanel::setDetachableHost(QMainWindow *host) { + m_detachableHost = host; + for (DetachableSection *section : findChildren()) + section->setHost(host); +} + void ParameterPanel::updateUI() { ParameterState state = m_stateGetter(); @@ -1204,7 +1220,8 @@ QWidget * ParameterPanel::createDetachableSection( const QString &title, QWidget *content, std::function beforeDetach) { - return new DetachableSection(title, content, std::move(beforeDetach)); + return new DetachableSection(title, content, std::move(beforeDetach), + m_detachableHost.data()); } diff --git a/src/qtgui/ParameterPanel.h b/src/qtgui/ParameterPanel.h index b6111cef..0e0b0921 100644 --- a/src/qtgui/ParameterPanel.h +++ b/src/qtgui/ParameterPanel.h @@ -3,6 +3,7 @@ #include "ParameterState.h" #include +#include #include #include #include @@ -15,6 +16,7 @@ namespace colorscreen { class image_data; } +class QMainWindow; class QVBoxLayout; class QFormLayout; class QGroupBox; @@ -36,6 +38,10 @@ class ParameterPanel : public QWidget { // Called when the external state changes (Undo/Redo, Code Load) virtual void updateUI(); + /** Pin detachable sections to HOST instead of following this panel's current + top-level window. Passing nullptr restores dynamic host selection. */ + void setDetachableHost(QMainWindow *host); + protected: /* Adds a double parameter row (SpinBox + Optional Combo). @@ -174,6 +180,7 @@ class ParameterPanel : public QWidget { StateGetter m_stateGetter; StateSetter m_stateSetter; ImageGetter m_imageGetter; + QPointer m_detachableHost; QFormLayout *m_currentGroupForm = nullptr; QVBoxLayout *m_layout; diff --git a/src/qtgui/main.cpp b/src/qtgui/main.cpp index 8412c69c..8eeb3b39 100644 --- a/src/qtgui/main.cpp +++ b/src/qtgui/main.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -927,6 +928,30 @@ int main(int argc, char *argv[]) { return; } + QMenu *viewFileMenu = nullptr; + for (QAction *action : view->menuBar()->actions()) { + if (QString(action->text()).remove('&') == QStringLiteral("File")) { + viewFileMenu = action->menu(); + break; + } + } + QStringList viewFileActions; + if (viewFileMenu) { + for (QAction *action : viewFileMenu->actions()) + if (action && !action->isSeparator()) + viewFileActions << QString(action->text()).remove('&'); + } + if (!viewFileMenu || + !viewFileActions.contains(QStringLiteral("Save Parameters")) || + !viewFileActions.contains(QStringLiteral("Exit")) || + !viewFileActions.contains(QStringLiteral("Close View")) || + viewFileActions.contains(QStringLiteral("Close Window"))) { + qCritical() << "New View File menu does not mirror document commands" + << viewFileActions; + app.exit(14); + return; + } + QCheckBox *viewColorToggle = nullptr; bool hasSelectTool = false; bool hasAddPointTool = false; @@ -1393,7 +1418,7 @@ int main(int argc, char *argv[]) { detachMtf->click(); QCoreApplication::processEvents(); QDockWidget *mtfDock = nullptr; - for (QDockWidget *candidate : workspace->findChildren()) { + for (QDockWidget *candidate : reference->findChildren()) { if (candidate && candidate->property("detachablePanel").toBool() && candidate->property("detachableTitle").toString() == QStringLiteral("MTF Chart") && From b9908f5f03c92a60ad2f3bda0c87053837dea655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hubi=C4=8Dka?= <46065755+janhubicka@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:09:10 +0200 Subject: [PATCH 3/8] qtgui: avoid metaobject casts for detachable sections --- src/qtgui/ParameterPanel.h | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/qtgui/ParameterPanel.h b/src/qtgui/ParameterPanel.h index 0e0b0921..e977444d 100644 --- a/src/qtgui/ParameterPanel.h +++ b/src/qtgui/ParameterPanel.h @@ -3,6 +3,7 @@ #include "ParameterState.h" #include +#include #include #include #include @@ -189,6 +190,18 @@ class ParameterPanel : public QWidget { std::vector> m_paramUpdaters; std::vector> m_widgetStateUpdaters; + // Qt 6.11 requires Q_OBJECT for QObject::findChildren(). Lightweight + // implementation-only helpers such as DetachableSection intentionally do not + // need meta-object data, so filter QWidget children with C++ RTTI instead. + template QList findChildren() const { + QList matches; + for (QWidget *child : QObject::findChildren()) { + if (T typedChild = dynamic_cast(child)) + matches.append(typedChild); + } + return matches; + } + virtual void onParametersRefreshed(const ParameterState &state) {} }; From 82a7fcb90fedcedc67ea9900d28a1bc110fb9e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hubi=C4=8Dka?= <46065755+janhubicka@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:40:12 +0200 Subject: [PATCH 4/8] qtgui: track detachable sections independently of layout --- src/qtgui/ParameterPanel.cpp | 15 +++++++++++---- src/qtgui/ParameterPanel.h | 15 +++------------ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/qtgui/ParameterPanel.cpp b/src/qtgui/ParameterPanel.cpp index a76df35e..04913d04 100644 --- a/src/qtgui/ParameterPanel.cpp +++ b/src/qtgui/ParameterPanel.cpp @@ -314,8 +314,8 @@ ParameterPanel::~ParameterPanel() = default; /** Override the generic dynamic dock host for specialized panel owners. */ void ParameterPanel::setDetachableHost(QMainWindow *host) { m_detachableHost = host; - for (DetachableSection *section : findChildren()) - section->setHost(host); + for (const auto &updateHost : m_detachableHostUpdaters) + updateHost(host); } void ParameterPanel::updateUI() { @@ -1220,8 +1220,15 @@ QWidget * ParameterPanel::createDetachableSection( const QString &title, QWidget *content, std::function beforeDetach) { - return new DetachableSection(title, content, std::move(beforeDetach), - m_detachableHost.data()); + auto *section = new DetachableSection(title, content, std::move(beforeDetach), + m_detachableHost.data(), this); + QPointer guardedSection(section); + m_detachableHostUpdaters.push_back( + [guardedSection](QMainWindow *host) { + if (guardedSection) + guardedSection->setHost(host); + }); + return section; } diff --git a/src/qtgui/ParameterPanel.h b/src/qtgui/ParameterPanel.h index e977444d..ca2982a7 100644 --- a/src/qtgui/ParameterPanel.h +++ b/src/qtgui/ParameterPanel.h @@ -3,7 +3,6 @@ #include "ParameterState.h" #include -#include #include #include #include @@ -190,17 +189,9 @@ class ParameterPanel : public QWidget { std::vector> m_paramUpdaters; std::vector> m_widgetStateUpdaters; - // Qt 6.11 requires Q_OBJECT for QObject::findChildren(). Lightweight - // implementation-only helpers such as DetachableSection intentionally do not - // need meta-object data, so filter QWidget children with C++ RTTI instead. - template QList findChildren() const { - QList matches; - for (QWidget *child : QObject::findChildren()) { - if (T typedChild = dynamic_cast(child)) - matches.append(typedChild); - } - return matches; - } + // Host propagation must not depend on QObject parenting: layouts can reparent + // detachable sections as inspectors move between presentations. + std::vector> m_detachableHostUpdaters; virtual void onParametersRefreshed(const ParameterState &state) {} }; From 9a53b02eca8aeed2498a114ac851d5322a24a25b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hubi=C4=8Dka?= <46065755+janhubicka@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:02:18 +0200 Subject: [PATCH 5/8] qtgui: host detachable sections in top-level window --- .agents/qtgui.md | 12 ++++++------ src/qtgui/ImageViewWindow.cpp | 1 - src/qtgui/main.cpp | 18 +++++++++++------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.agents/qtgui.md b/.agents/qtgui.md index 3e43a47b..dc339937 100644 --- a/.agents/qtgui.md +++ b/.agents/qtgui.md @@ -140,12 +140,12 @@ limited to **Original digital capture** and **Image layer**. Measurements are applied through the source document's undoable parameter path. Every panel section created by `ParameterPanel::createDetachableSection()` owns its own standard floating-dock lifecycle. This is the only detach implementation: document windows, ordinary views, and specialized reference views do not create -parallel chart docks or probe nested layouts during reattachment. By default a detached -section follows the top-level window currently presenting its inspector and always -returns its content when closed. Specialized inspectors may pin that same generic -dock lifecycle to their logical presentation; slanted-edge Sharpness sections use -the reference `ImageViewWindow` as that host. This keeps reference diagnostics -presentation-owned without reintroducing panel-specific dock wiring. **Reload and demosaic** reloads both the source scan and every associated +parallel chart docks or probe nested layouts during reattachment. A detached section follows the actual top-level window currently presenting its +inspector and always returns its content when closed. This rule applies uniformly +to document, ordinary-view, and slanted-edge reference inspectors: while embedded, +the workspace owns their floating docks; after detaching a presentation, that +presentation window becomes the dock host. This avoids nested `QMainWindow` dock +ownership without reintroducing panel-specific wiring. **Reload and demosaic** reloads both the source scan and every associated slanted-edge reference from its own filename using the current demosaic mode. **Window → New View** creates another MDI view of the same document. Ordinary diff --git a/src/qtgui/ImageViewWindow.cpp b/src/qtgui/ImageViewWindow.cpp index c7d13f84..9bfd09b2 100644 --- a/src/qtgui/ImageViewWindow.cpp +++ b/src/qtgui/ImageViewWindow.cpp @@ -438,7 +438,6 @@ void ImageViewWindow::setupReferenceInspector() { m_document->applySharedDocumentState(state, description); }, [this]() { return m_scan; }, m_referenceTabs); - m_sharpnessPanel->setDetachableHost(this); m_referenceTabs->addTab(m_sharpnessPanel, tr("Sharpness")); connect(m_sharpnessPanel, diff --git a/src/qtgui/main.cpp b/src/qtgui/main.cpp index 8eeb3b39..a07b4257 100644 --- a/src/qtgui/main.cpp +++ b/src/qtgui/main.cpp @@ -1417,14 +1417,18 @@ int main(int argc, char *argv[]) { } detachMtf->click(); QCoreApplication::processEvents(); + QMainWindow *mtfHost = + qobject_cast(mtfSection->window()); QDockWidget *mtfDock = nullptr; - for (QDockWidget *candidate : reference->findChildren()) { - if (candidate && candidate->property("detachablePanel").toBool() && - candidate->property("detachableTitle").toString() == - QStringLiteral("MTF Chart") && - candidate->isAncestorOf(mtfChart)) { - mtfDock = candidate; - break; + if (mtfHost) { + for (QDockWidget *candidate : mtfHost->findChildren()) { + if (candidate && candidate->property("detachablePanel").toBool() && + candidate->property("detachableTitle").toString() == + QStringLiteral("MTF Chart") && + candidate->isAncestorOf(mtfChart)) { + mtfDock = candidate; + break; + } } } if (!mtfDock || !mtfDock->isVisible() || !mtfDock->isFloating() || From 4a42f3384f5765b9b536117ceeae18c58a57cb5b Mon Sep 17 00:00:00 2001 From: OpenAI Date: Sat, 29 Aug 2026 17:30:50 +0000 Subject: [PATCH 6/8] qtgui: host detachable sections in top-level window --- src/qtgui/main.cpp | 44 +++++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/src/qtgui/main.cpp b/src/qtgui/main.cpp index a07b4257..bf2ad2ee 100644 --- a/src/qtgui/main.cpp +++ b/src/qtgui/main.cpp @@ -1390,10 +1390,9 @@ int main(int argc, char *argv[]) { return; } - // Reproduce the MTF-detach failure with the source and specialized - // reference visible as MDI tiles. The reference owns its Sharpness - // panel, so the detached chart must be adopted by a dock belonging to - // that view rather than disappearing from the detachable section. + // Reproduce reference-panel detachment while source and reference are + // visible as MDI tiles. The section must use the actual top-level + // presentation host, exactly like every other ParameterPanel section. workspace->tileDocuments(); workspace->activateView(reference); QCoreApplication::processEvents(); @@ -1410,29 +1409,44 @@ int main(int argc, char *argv[]) { } } } - if (!sharpness || !mtfChart || !detachMtf) { + if (!sharpness || !mtfChart || !mtfSection || !detachMtf) { qCritical() << "Could not locate reference MTF detachable section"; app.exit(15); return; } - detachMtf->click(); + + // processEvents() may run the overall smoke shutdown timer on very slow + // sanitizer builds. Never retain raw child pointers across that turn. + QPointer guardedSharpness(sharpness); + QPointer guardedMtfChart(mtfChart); + QPointer guardedMtfSection(mtfSection); + QPointer guardedDetachMtf(detachMtf); + guardedDetachMtf->click(); QCoreApplication::processEvents(); + if (!guardedReference || !guardedSharpness || !guardedMtfChart || + !guardedMtfSection || !guardedDetachMtf) { + qCritical() << "Reference MTF section disappeared during detach"; + app.exit(15); + return; + } + QMainWindow *mtfHost = - qobject_cast(mtfSection->window()); + qobject_cast(guardedMtfSection->window()); QDockWidget *mtfDock = nullptr; if (mtfHost) { for (QDockWidget *candidate : mtfHost->findChildren()) { if (candidate && candidate->property("detachablePanel").toBool() && candidate->property("detachableTitle").toString() == QStringLiteral("MTF Chart") && - candidate->isAncestorOf(mtfChart)) { + candidate->isAncestorOf(guardedMtfChart.data())) { mtfDock = candidate; break; } } } if (!mtfDock || !mtfDock->isVisible() || !mtfDock->isFloating() || - !mtfDock->widget() || !mtfDock->isAncestorOf(mtfChart)) { + !mtfDock->widget() || + !mtfDock->isAncestorOf(guardedMtfChart.data())) { qCritical() << "Reference MTF chart disappeared instead of detaching"; app.exit(15); return; @@ -1441,13 +1455,21 @@ int main(int argc, char *argv[]) { mtfDock->close(); QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); QCoreApplication::processEvents(); + if (!guardedReference || !guardedMtfChart || !guardedDetachMtf) { + qCritical() << "Reference MTF section disappeared during reattach"; + app.exit(15); + return; + } if (guardedMtfDock && guardedMtfDock->widget()) { qCritical() << "Generic MTF dock retained its content after close"; app.exit(15); return; } - if (!reference->workspaceInspectorWidget()->isAncestorOf(mtfChart) || - detachMtf->text() != QStringLiteral("Detach")) { + QWidget *referenceInspector = + guardedReference->workspaceInspectorWidget(); + if (!referenceInspector || + !referenceInspector->isAncestorOf(guardedMtfChart.data()) || + guardedDetachMtf->text() != QStringLiteral("Detach")) { qCritical() << "Reference MTF chart did not reattach after dock close"; app.exit(15); return; From 166315d8e38d96169cc6487a440de30e08f1cae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hubi=C4=8Dka?= <46065755+janhubicka@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:33:14 +0200 Subject: [PATCH 7/8] qtgui: make sanitizer smoke lifetime-safe Guard reference-panel smoke objects with QPointer across event-loop turns so a slow sanitizer run cannot dereference widgets after teardown. Give the deliberately serial New View + slanted-reference smoke a 60-second minimum lifetime, preventing the cleanup timer from racing ARM64 ASan while preserving the full test sequence. --- src/qtgui/main.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/qtgui/main.cpp b/src/qtgui/main.cpp index bf2ad2ee..7c7cf998 100644 --- a/src/qtgui/main.cpp +++ b/src/qtgui/main.cpp @@ -1616,6 +1616,12 @@ int main(int argc, char *argv[]) { int duration = parser.value(smokeTestOption).toInt(&converted); if (!converted || duration <= 0) duration = 5000; + // New View and slanted-reference checks deliberately run serially because + // both manipulate shared document presentation. Sanitizer builds, + // especially ARM64 ASan, need more than the ordinary 30-second smoke + // window; do not let the cleanup timer destroy their widgets mid-check. + if (parser.isSet(newViewOption) && parser.isSet(slantedReferenceOption)) + duration = qMax(duration, 60000); qDebug() << "Smoke Test Mode: Will exit in" << duration << "ms..."; QTimer::singleShot(duration, &app, [&app]() { app.closeAllDocumentWindows(); From dabc1083f4ace1d3660b6197881220d94afdbb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hubi=C4=8Dka?= <46065755+janhubicka@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:22:33 +0200 Subject: [PATCH 8/8] qtgui: tighten workspace ownership lifetimes Remove the obsolete detachable-host pinning API now that sections always follow their actual top-level presentation. Keep workspace progress signal subscriptions document-lifetime so detach/reattach cannot accumulate duplicate callbacks. Make structured GUI smoke tests completion-driven and use the duration only as a failure watchdog, preserving QPointer guards across event-loop turns. --- src/qtgui/ParameterPanel.cpp | 30 ++-------------- src/qtgui/ParameterPanel.h | 11 ------ src/qtgui/WorkspaceWindow.cpp | 19 ++++++++++ src/qtgui/WorkspaceWindow.h | 1 + src/qtgui/main.cpp | 65 ++++++++++++++++++++++++++--------- 5 files changed, 72 insertions(+), 54 deletions(-) diff --git a/src/qtgui/ParameterPanel.cpp b/src/qtgui/ParameterPanel.cpp index 04913d04..7242946e 100644 --- a/src/qtgui/ParameterPanel.cpp +++ b/src/qtgui/ParameterPanel.cpp @@ -34,10 +34,10 @@ namespace { class DetachableSection final : public QWidget { public: DetachableSection(const QString &title, QWidget *content, - std::function beforeDetach, QMainWindow *host, + std::function beforeDetach, QWidget *parent = nullptr) : QWidget(parent), m_title(title), m_content(content), - m_beforeDetach(std::move(beforeDetach)), m_pinnedHost(host) { + m_beforeDetach(std::move(beforeDetach)) { setObjectName(QStringLiteral("DetachableSection")); setProperty("detachableTitle", title); @@ -81,12 +81,6 @@ class DetachableSection final : public QWidget { ~DetachableSection() override { reattach(false); } - /** Pin this section to HOST, or resume following its containing window. */ - void setHost(QMainWindow *host) { - m_pinnedHost = host; - migrateDockToCurrentHost(); - } - protected: bool eventFilter(QObject *watched, QEvent *event) override { if (watched == m_dock.data() && event && event->type() == QEvent::Close) { @@ -115,8 +109,6 @@ class DetachableSection final : public QWidget { }; QMainWindow *currentHost() const { - if (m_pinnedHost) - return m_pinnedHost.data(); return qobject_cast(window()); } @@ -271,7 +263,6 @@ class DetachableSection final : public QWidget { QString m_title; QPointer m_content; std::function m_beforeDetach; - QPointer m_pinnedHost; QVBoxLayout *m_layout = nullptr; QPushButton *m_button = nullptr; QPointer m_dock; @@ -311,13 +302,6 @@ ParameterPanel::ParameterPanel(StateGetter stateGetter, StateSetter stateSetter, ParameterPanel::~ParameterPanel() = default; -/** Override the generic dynamic dock host for specialized panel owners. */ -void ParameterPanel::setDetachableHost(QMainWindow *host) { - m_detachableHost = host; - for (const auto &updateHost : m_detachableHostUpdaters) - updateHost(host); -} - void ParameterPanel::updateUI() { ParameterState state = m_stateGetter(); @@ -1220,15 +1204,7 @@ QWidget * ParameterPanel::createDetachableSection( const QString &title, QWidget *content, std::function beforeDetach) { - auto *section = new DetachableSection(title, content, std::move(beforeDetach), - m_detachableHost.data(), this); - QPointer guardedSection(section); - m_detachableHostUpdaters.push_back( - [guardedSection](QMainWindow *host) { - if (guardedSection) - guardedSection->setHost(host); - }); - return section; + return new DetachableSection(title, content, std::move(beforeDetach), this); } diff --git a/src/qtgui/ParameterPanel.h b/src/qtgui/ParameterPanel.h index ca2982a7..b6111cef 100644 --- a/src/qtgui/ParameterPanel.h +++ b/src/qtgui/ParameterPanel.h @@ -3,7 +3,6 @@ #include "ParameterState.h" #include -#include #include #include #include @@ -16,7 +15,6 @@ namespace colorscreen { class image_data; } -class QMainWindow; class QVBoxLayout; class QFormLayout; class QGroupBox; @@ -38,10 +36,6 @@ class ParameterPanel : public QWidget { // Called when the external state changes (Undo/Redo, Code Load) virtual void updateUI(); - /** Pin detachable sections to HOST instead of following this panel's current - top-level window. Passing nullptr restores dynamic host selection. */ - void setDetachableHost(QMainWindow *host); - protected: /* Adds a double parameter row (SpinBox + Optional Combo). @@ -180,7 +174,6 @@ class ParameterPanel : public QWidget { StateGetter m_stateGetter; StateSetter m_stateSetter; ImageGetter m_imageGetter; - QPointer m_detachableHost; QFormLayout *m_currentGroupForm = nullptr; QVBoxLayout *m_layout; @@ -189,10 +182,6 @@ class ParameterPanel : public QWidget { std::vector> m_paramUpdaters; std::vector> m_widgetStateUpdaters; - // Host propagation must not depend on QObject parenting: layouts can reparent - // detachable sections as inspectors move between presentations. - std::vector> m_detachableHostUpdaters; - virtual void onParametersRefreshed(const ParameterState &state) {} }; diff --git a/src/qtgui/WorkspaceWindow.cpp b/src/qtgui/WorkspaceWindow.cpp index fad87baa..d2aaef33 100644 --- a/src/qtgui/WorkspaceWindow.cpp +++ b/src/qtgui/WorkspaceWindow.cpp @@ -750,7 +750,19 @@ void WorkspaceWindow::attachDocumentProgress(MainWindow *document) { qMax(progress->minimumHeight(), progress->sizeHint().height())); statusBar()->setMinimumHeight(stableHeight); } + } + + bool signalsConnected = false; + for (const QPointer &candidate : + std::as_const(m_progressSignalDocuments)) { + if (candidate == document) { + signalsConnected = true; + break; + } + } + if (!signalsConnected) { + m_progressSignalDocuments.append(document); QPointer guardedDocument(document); connect(document, &MainWindow::transientProgressVisibilityChanged, this, [this, guardedDocument](bool visible) { @@ -773,6 +785,13 @@ void WorkspaceWindow::attachDocumentProgress(MainWindow *document) { else ++it; } + for (auto it = m_progressSignalDocuments.begin(); + it != m_progressSignalDocuments.end();) { + if (it->isNull()) + it = m_progressSignalDocuments.erase(it); + else + ++it; + } if (!m_displayedProgressDocument) m_displayedProgressDocument.clear(); updateWorkspaceProgressPresentation(); diff --git a/src/qtgui/WorkspaceWindow.h b/src/qtgui/WorkspaceWindow.h index 91b47f4a..0da21566 100644 --- a/src/qtgui/WorkspaceWindow.h +++ b/src/qtgui/WorkspaceWindow.h @@ -220,6 +220,7 @@ class WorkspaceWindow final : public QMainWindow { QToolButton *m_workspaceProgressPreviousButton = nullptr; QToolButton *m_workspaceProgressNextButton = nullptr; QList> m_progressDocuments; + QList> m_progressSignalDocuments; QPointer m_displayedProgressDocument; QWidget *m_userVisibleProgressStack = nullptr; QVBoxLayout *m_userVisibleProgressLayout = nullptr; diff --git a/src/qtgui/main.cpp b/src/qtgui/main.cpp index 7c7cf998..0c28ac35 100644 --- a/src/qtgui/main.cpp +++ b/src/qtgui/main.cpp @@ -791,9 +791,27 @@ int main(int argc, char *argv[]) { // delays that processEvents() can overtake on slow or instrumented builds. const auto newViewSmokeDone = std::make_shared(!parser.isSet(newViewOption)); + const auto slantedReferenceSmokeDone = + std::make_shared(!parser.isSet(slantedReferenceOption)); + const bool completionManagedSmoke = + parser.isSet(smokeTestOption) && + (parser.isSet(newViewOption) || parser.isSet(slantedReferenceOption)) && + !parser.isSet(userVisibleProgressOption) && + !parser.isSet(windowLifetimeOption) && + !parser.isSet(closeToEmptyTabOption); + const auto maybeFinishStructuredSmoke = + std::make_shared>(); + *maybeFinishStructuredSmoke = + [&app, newViewSmokeDone, slantedReferenceSmokeDone, + completionManagedSmoke]() { + if (completionManagedSmoke && *newViewSmokeDone && + *slantedReferenceSmokeDone) + QTimer::singleShot(0, &app, [&app]() { app.quit(); }); + }; if (parser.isSet(newViewOption)) { - QTimer::singleShot(300, &app, [&app, newViewSmokeDone]() { + QTimer::singleShot(300, &app, + [&app, newViewSmokeDone, maybeFinishStructuredSmoke]() { const QList documents = app.documentWindows(); if (documents.isEmpty() || !documents.front()->sharedImageData()) { qCritical() << "New View smoke test requires a loaded document"; @@ -1147,7 +1165,7 @@ int main(int argc, char *argv[]) { checkFinalPeerClose; *checkFinalPeerClose = [&app, guardedSource, guardedView, documentCount, newViewSmokeDone, - weakCheckFinalPeerClose](int attemptsLeft) { + maybeFinishStructuredSmoke, weakCheckFinalPeerClose](int attemptsLeft) { if (guardedSource || guardedView || app.documentWindows().size() != documentCount - 1) { if (attemptsLeft > 0) { @@ -1163,6 +1181,7 @@ int main(int argc, char *argv[]) { return; } *newViewSmokeDone = true; + (*maybeFinishStructuredSmoke)(); }; QTimer::singleShot(0, &app, [checkFinalPeerClose]() { (*checkFinalPeerClose)(40); @@ -1287,7 +1306,8 @@ int main(int argc, char *argv[]) { const std::weak_ptr> weakStartReferenceSmoke = startReferenceSmoke; *startReferenceSmoke = - [&app, newViewSmokeDone, weakStartReferenceSmoke](int attemptsLeft) { + [&app, newViewSmokeDone, slantedReferenceSmokeDone, + maybeFinishStructuredSmoke, weakStartReferenceSmoke](int attemptsLeft) { if (!*newViewSmokeDone) { if (attemptsLeft <= 0) { qCritical() << "Slanted reference smoke test timed out waiting for " @@ -1345,7 +1365,9 @@ int main(int argc, char *argv[]) { const std::weak_ptr> weakCheckReference = checkReference; *checkReference = [&app, guardedSource, guardedReference, documentCount, - tabCount, weakCheckReference](int attemptsLeft) { + tabCount, slantedReferenceSmokeDone, + maybeFinishStructuredSmoke, + weakCheckReference](int attemptsLeft) { MainWindow *source = guardedSource.data(); ImageViewWindow *reference = guardedReference.data(); if (!source || !reference) { @@ -1415,8 +1437,8 @@ int main(int argc, char *argv[]) { return; } - // processEvents() may run the overall smoke shutdown timer on very slow - // sanitizer builds. Never retain raw child pointers across that turn. + // Teardown can be queued by another presentation operation while this + // check yields to Qt. Never retain raw child pointers across that turn. QPointer guardedSharpness(sharpness); QPointer guardedMtfChart(mtfChart); QPointer guardedMtfSection(mtfSection); @@ -1544,6 +1566,7 @@ int main(int argc, char *argv[]) { const std::weak_ptr> weakCheckReload = checkReload; *checkReload = [&app, guardedSource, guardedReference, beforeReload, + slantedReferenceSmokeDone, maybeFinishStructuredSmoke, weakCheckReload](int attemptsLeft) { MainWindow *source = guardedSource.data(); ImageViewWindow *reference = guardedReference.data(); @@ -1601,6 +1624,8 @@ int main(int argc, char *argv[]) { } for (ImageViewWindow *view : references) app.closeView(view); + *slantedReferenceSmokeDone = true; + (*maybeFinishStructuredSmoke)(); }; (*checkReload)(28); }; @@ -1616,17 +1641,25 @@ int main(int argc, char *argv[]) { int duration = parser.value(smokeTestOption).toInt(&converted); if (!converted || duration <= 0) duration = 5000; - // New View and slanted-reference checks deliberately run serially because - // both manipulate shared document presentation. Sanitizer builds, - // especially ARM64 ASan, need more than the ordinary 30-second smoke - // window; do not let the cleanup timer destroy their widgets mid-check. - if (parser.isSet(newViewOption) && parser.isSet(slantedReferenceOption)) + // New View and slanted-reference checks manipulate shared presentation + // serially. Give their watchdog enough room under instrumentation, but + // successful structured checks quit immediately when they are complete. + if (completionManagedSmoke && parser.isSet(newViewOption) && + parser.isSet(slantedReferenceOption)) duration = qMax(duration, 60000); - qDebug() << "Smoke Test Mode: Will exit in" << duration << "ms..."; - QTimer::singleShot(duration, &app, [&app]() { - app.closeAllDocumentWindows(); - app.quit(); - }); + qDebug() << "Smoke Test Mode: watchdog is" << duration << "ms"; + QTimer::singleShot( + duration, &app, + [&app, completionManagedSmoke, newViewSmokeDone, + slantedReferenceSmokeDone]() { + if (completionManagedSmoke && + (!*newViewSmokeDone || !*slantedReferenceSmokeDone)) { + qCritical() << "Structured GUI smoke test timed out before completion"; + app.exit(17); + return; + } + app.quit(); + }); } // WorkspaceWindow deliberately survives Close while detached peer windows