summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTor Arne Vestbø <tor.arne.vestbo@qt.io>2022-10-26 19:32:02 +0200
committerTor Arne Vestbø <tor.arne.vestbo@qt.io>2022-11-15 20:36:17 +0100
commita47c7a98264c85e9ff0bc3e5a42b9554dd20576e (patch)
treed5e38ca7948a87aa657030e2ddc6716926d4606e
parentda0587c43a611cd5d74119ee4a62d0a8767b4d8e (diff)
macOS: Add dialog helper for native message boxes
The native implementation uses NSAlert, making a best effort to map the QMessageBox properties to the native dialog, falling back to the cross platform non-native dialog if the discrepancy is too big. The initial implementation focuses on the current state of the native dialog helper "protocol", but there's room for improvement here, which would allow even more dialog types and properties to be native. [ChangeLog][macOS] Message boxes such as QMessageBox now follow the platform look and feel by using native dialogs if possible. Change-Id: I4da33f99894194a7b301628cd1fbb44d646ddf18 Reviewed-by: Volker Hilsheimer <volker.hilsheimer@qt.io>
-rw-r--r--src/plugins/platforms/cocoa/CMakeLists.txt1
-rw-r--r--src/plugins/platforms/cocoa/qcocoahelpers.h1
-rw-r--r--src/plugins/platforms/cocoa/qcocoahelpers.mm1
-rw-r--r--src/plugins/platforms/cocoa/qcocoamessagedialog.h37
-rw-r--r--src/plugins/platforms/cocoa/qcocoamessagedialog.mm304
-rw-r--r--src/plugins/platforms/cocoa/qcocoatheme.mm17
-rw-r--r--src/plugins/platforms/cocoa/qnsview.h2
-rw-r--r--src/plugins/platforms/cocoa/qnswindow.h3
-rw-r--r--tests/auto/widgets/dialogs/qmessagebox/tst_qmessagebox.cpp35
9 files changed, 395 insertions, 6 deletions
diff --git a/src/plugins/platforms/cocoa/CMakeLists.txt b/src/plugins/platforms/cocoa/CMakeLists.txt
index c5f71316da..cc4722cb3e 100644
--- a/src/plugins/platforms/cocoa/CMakeLists.txt
+++ b/src/plugins/platforms/cocoa/CMakeLists.txt
@@ -48,6 +48,7 @@ qt_internal_add_plugin(QCocoaIntegrationPlugin
qcocoacolordialoghelper.h qcocoacolordialoghelper.mm
qcocoafiledialoghelper.h qcocoafiledialoghelper.mm
qcocoafontdialoghelper.h qcocoafontdialoghelper.mm
+ qcocoamessagedialog.h qcocoamessagedialog.mm
DEFINES
QT_NO_FOREACH
LIBRARIES
diff --git a/src/plugins/platforms/cocoa/qcocoahelpers.h b/src/plugins/platforms/cocoa/qcocoahelpers.h
index 1964dcf59d..369f752dc9 100644
--- a/src/plugins/platforms/cocoa/qcocoahelpers.h
+++ b/src/plugins/platforms/cocoa/qcocoahelpers.h
@@ -41,6 +41,7 @@ Q_DECLARE_LOGGING_CATEGORY(lcQpaScreen)
Q_DECLARE_LOGGING_CATEGORY(lcQpaApplication)
Q_DECLARE_LOGGING_CATEGORY(lcQpaClipboard)
Q_DECLARE_LOGGING_CATEGORY(lcInputDevices)
+Q_DECLARE_LOGGING_CATEGORY(lcQpaDialogs)
class QPixmap;
class QString;
diff --git a/src/plugins/platforms/cocoa/qcocoahelpers.mm b/src/plugins/platforms/cocoa/qcocoahelpers.mm
index 0a6a2a7d04..0810324784 100644
--- a/src/plugins/platforms/cocoa/qcocoahelpers.mm
+++ b/src/plugins/platforms/cocoa/qcocoahelpers.mm
@@ -28,6 +28,7 @@ Q_LOGGING_CATEGORY(lcQpaScreen, "qt.qpa.screen", QtCriticalMsg);
Q_LOGGING_CATEGORY(lcQpaApplication, "qt.qpa.application");
Q_LOGGING_CATEGORY(lcQpaClipboard, "qt.qpa.clipboard")
Q_LOGGING_CATEGORY(lcInputDevices, "qt.qpa.input.devices")
+Q_LOGGING_CATEGORY(lcQpaDialogs, "qt.qpa.dialogs")
//
// Conversion Functions
diff --git a/src/plugins/platforms/cocoa/qcocoamessagedialog.h b/src/plugins/platforms/cocoa/qcocoamessagedialog.h
new file mode 100644
index 0000000000..564dd915c5
--- /dev/null
+++ b/src/plugins/platforms/cocoa/qcocoamessagedialog.h
@@ -0,0 +1,37 @@
+// Copyright (C) 2022 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+#ifndef QCOCOAMESSAGEDIALOG_H
+#define QCOCOAMESSAGEDIALOG_H
+
+#include <qpa/qplatformdialoghelper.h>
+
+Q_FORWARD_DECLARE_OBJC_CLASS(NSAlert);
+typedef long NSInteger;
+typedef NSInteger NSModalResponse;
+
+QT_BEGIN_NAMESPACE
+
+class QEventLoop;
+
+class QCocoaMessageDialog : public QPlatformMessageDialogHelper
+{
+public:
+ QCocoaMessageDialog() = default;
+ ~QCocoaMessageDialog();
+
+ void exec() override;
+ bool show(Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent) override;
+ void hide() override;
+
+private:
+ Qt::WindowModality modality() const;
+ NSAlert *m_alert = nullptr;
+ QEventLoop *m_eventLoop = nullptr;
+ void processResponse(NSModalResponse response);
+};
+
+QT_END_NAMESPACE
+
+#endif // QCOCOAMESSAGEDIALOG_H
+
diff --git a/src/plugins/platforms/cocoa/qcocoamessagedialog.mm b/src/plugins/platforms/cocoa/qcocoamessagedialog.mm
new file mode 100644
index 0000000000..c21fbc2ce4
--- /dev/null
+++ b/src/plugins/platforms/cocoa/qcocoamessagedialog.mm
@@ -0,0 +1,304 @@
+// Copyright (C) 2022 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+#include "qcocoamessagedialog.h"
+
+#include "qcocoawindow.h"
+#include "qcocoahelpers.h"
+
+#include <QtCore/qmetaobject.h>
+#include <QtCore/qscopedvaluerollback.h>
+#include <QtCore/qtimer.h>
+
+#include <QtGui/qtextdocument.h>
+#include <QtGui/private/qguiapplication_p.h>
+#include <QtGui/qpa/qplatformtheme.h>
+
+#include <AppKit/NSAlert.h>
+#include <AppKit/NSButton.h>
+
+QT_USE_NAMESPACE
+
+using namespace Qt::StringLiterals;
+
+QT_BEGIN_NAMESPACE
+
+QCocoaMessageDialog::~QCocoaMessageDialog()
+{
+ hide();
+ [m_alert release];
+}
+
+static QString toPlainText(const QString &text)
+{
+ // FIXME: QMessageDialog supports Qt::TextFormat, which
+ // nowadays includes Qt::MarkdownText, but we don't have
+ // the machinery to deal with that yet. We should as a
+ // start plumb the dialog's text format to the platform
+ // via the dialog options.
+
+ if (!Qt::mightBeRichText(text))
+ return text;
+
+ QTextDocument textDocument;
+ textDocument.setHtml(text);
+ return textDocument.toPlainText();
+}
+
+/*
+ Called from QDialogPrivate::setNativeDialogVisible() when the message box
+ is ready to be shown.
+
+ At this point the options() will reflect the specific dialog shown.
+
+ Returns true if the helper could successfully show the dialog, or
+ false if the cross platform fallback dialog should be used instead.
+*/
+bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent)
+{
+ Q_UNUSED(windowFlags);
+
+ qCDebug(lcQpaDialogs) << "Asked to show" << windowModality << "dialog with parent" << parent;
+
+ if (m_alert.window.visible) {
+ qCDebug(lcQpaDialogs) << "Dialog already visible, ignoring request to show";
+ return true; // But we don't want to show the fallback dialog instead
+ }
+
+ // We can only do application and window modal dialogs
+ if (windowModality == Qt::NonModal)
+ return false;
+
+ // And only window modal if we have a parent
+ if (windowModality == Qt::WindowModal && (!parent || !parent->handle())) {
+ qCWarning(lcQpaDialogs, "Cannot run window modal dialog without parent window");
+ return false;
+ }
+
+ // And without options we don't know what to show
+ if (!options())
+ return false;
+
+ Q_ASSERT(!m_alert);
+ m_alert = [NSAlert new];
+ m_alert.window.title = options()->windowTitle().toNSString();
+
+ QString text = toPlainText(options()->text());
+ QString details = toPlainText(options()->detailedText());
+ if (!details.isEmpty())
+ text += u"\n\n"_s + details;
+ m_alert.messageText = text.toNSString();
+ m_alert.informativeText = toPlainText(options()->informativeText()).toNSString();
+
+ switch (options()->icon()) {
+ case QMessageDialogOptions::NoIcon:
+ case QMessageDialogOptions::Information:
+ case QMessageDialogOptions::Question:
+ [m_alert setAlertStyle:NSAlertStyleInformational];
+ break;
+ case QMessageDialogOptions::Warning:
+ [m_alert setAlertStyle:NSAlertStyleWarning];
+ break;
+ case QMessageDialogOptions::Critical:
+ [m_alert setAlertStyle:NSAlertStyleCritical];
+ break;
+ }
+
+ // FIXME: Propagate iconPixmap through dialog options
+
+ bool defaultButtonAdded = false;
+ bool cancelButtonAdded = false;
+
+ const auto addButton = [&](auto title, auto tag, auto role) {
+ title = QPlatformTheme::removeMnemonics(title);
+ NSButton *button = [m_alert addButtonWithTitle:title.toNSString()];
+
+ // Calling addButtonWithTitle places buttons starting at the right side/top of the alert
+ // and going toward the left/bottom. By default, the first button has a key equivalent of
+ // Return, any button with a title of "Cancel" has a key equivalent of Escape, and any button
+ // with the title "Don't Save" has a key equivalent of Command-D (but only if it's not the first
+ // button). Unfortunately QMessageBox does not currently plumb setDefaultButton/setEscapeButton
+ // through the dialog options, so we can't forward this information directly. The closest we
+ // can get right now is to use the role to set the button's key equivalent.
+
+ if (role == AcceptRole && !defaultButtonAdded) {
+ button.keyEquivalent = @"\r";
+ defaultButtonAdded = true;
+ } else if (role == RejectRole && !cancelButtonAdded) {
+ button.keyEquivalent = @"\e";
+ cancelButtonAdded = true;
+ }
+
+ if (@available(macOS 11, *))
+ button.hasDestructiveAction = role == DestructiveRole;
+
+ // The NSModalResponse of showing an NSAlert normally depends on the order of the
+ // button that was clicked, starting from the right with NSAlertFirstButtonReturn (1000),
+ // NSAlertSecondButtonReturn (1001), NSAlertThirdButtonReturn (1002), and after that
+ // NSAlertThirdButtonReturn + n. The response can also be customized per button via its
+ // tag, which, following the above logic, can include any positive value from 1000 and up.
+ // In addition the system reserves the values from -1000 and down for its own modal responses,
+ // such as NSModalResponseStop, NSModalResponseAbort, and NSModalResponseContinue.
+ // Luckily for us, the QPlatformDialogHelper::StandardButton enum values all fall within
+ // the positive range, so we can use the standard button value as the tag directly.
+ // The same applies to the custom button IDs, as these are generated in sequence after
+ // the QPlatformDialogHelper::LastButton.
+ Q_ASSERT(tag >= NSAlertFirstButtonReturn);
+ button.tag = tag;
+ };
+
+ const auto *platformTheme = QGuiApplicationPrivate::platformTheme();
+ if (auto standardButtons = options()->standardButtons()) {
+ for (int standardButton = FirstButton; standardButton < LastButton; standardButton <<= 1) {
+ if (standardButtons & standardButton) {
+ auto title = platformTheme->standardButtonText(standardButton);
+ addButton(title, standardButton, buttonRole(StandardButton(standardButton)));
+ }
+ }
+ }
+
+ const auto customButtons = options()->customButtons();
+ for (auto customButton : customButtons)
+ addButton(customButton.label, customButton.id, customButton.role);
+
+
+ // QMessageDialog's logic for adding a fallback OK button if no other buttons
+ // are added depends on QMessageBox::showEvent(), which is too late when
+ // native dialogs are in use. To ensure there's always an OK button with a tag
+ // we recognize we add it explicitly here as a fallback.
+ if (!m_alert.buttons.count) {
+ addButton(platformTheme->standardButtonText(StandardButton::Ok),
+ StandardButton::Ok, ButtonRole::AcceptRole);
+ }
+
+ qCDebug(lcQpaDialogs) << "Showing" << m_alert;
+
+ if (windowModality == Qt::WindowModal) {
+ auto *cocoaWindow = static_cast<QCocoaWindow*>(parent->handle());
+ [m_alert beginSheetModalForWindow:cocoaWindow->nativeWindow()
+ completionHandler:^(NSModalResponse response) {
+ processResponse(response);
+ }
+ ];
+ } else {
+ // The dialog is application modal, so we need to call runModal,
+ // but we can't call it here as the nativeDialogInUse state of QDialog
+ // depends on the result of show(), and we can't rely on doing it
+ // in exec(), as we can't guarantee that the user will call exec()
+ // after showing the dialog. As a workaround, we call it from exec(),
+ // but also make sure that if the user returns to the main runloop
+ // we'll run the modal dialog from there.
+ QTimer::singleShot(0, this, [this]{
+ if (m_alert && NSApp.modalWindow != m_alert.window) {
+ qCDebug(lcQpaDialogs) << "Running deferred modal" << m_alert;
+ processResponse([m_alert runModal]);
+ }
+ });
+ }
+
+ return true;
+}
+
+void QCocoaMessageDialog::exec()
+{
+ Q_ASSERT(m_alert);
+
+ if (modality() == Qt::WindowModal) {
+ qCDebug(lcQpaDialogs) << "Running local event loop for window modal" << m_alert;
+ QEventLoop eventLoop;
+ QScopedValueRollback updateGuard(m_eventLoop, &eventLoop);
+ m_eventLoop->exec(QEventLoop::DialogExec);
+ } else {
+ qCDebug(lcQpaDialogs) << "Running modal" << m_alert;
+ processResponse([m_alert runModal]);
+ }
+}
+
+// Custom modal response code to record that the dialog was hidden by us
+static const NSInteger kModalResponseDialogHidden = NSAlertThirdButtonReturn + 1;
+
+void QCocoaMessageDialog::processResponse(NSModalResponse response)
+{
+ qCDebug(lcQpaDialogs) << "Processing response" << response << "for" << m_alert;
+
+ if (response >= NSAlertFirstButtonReturn) {
+ // Safe range for user-defined modal responses
+ if (response == kModalResponseDialogHidden) {
+ // Dialog was explicitly hidden by us, so nothing to report
+ qCDebug(lcQpaDialogs) << "Dialog was hidden; ignoring response";
+ } else {
+ // Dialog buttons
+ if (response <= StandardButton::LastButton) {
+ Q_ASSERT(response >= StandardButton::FirstButton);
+ auto standardButton = StandardButton(response);
+ emit clicked(standardButton, buttonRole(standardButton));
+ } else {
+ auto *customButton = options()->customButton(response);
+ Q_ASSERT(customButton);
+ emit clicked(StandardButton(customButton->id), customButton->role);
+ }
+ }
+ } else {
+ // We have to consider NSModalResponses beyond the ones specific to
+ // the alert buttons as the alert may be canceled programmatically.
+
+ switch (response) {
+ case NSModalResponseContinue:
+ // Modal session is continuing (returned by runModalSession: only)
+ Q_UNREACHABLE();
+ case NSModalResponseOK:
+ emit accept();
+ break;
+ case NSModalResponseCancel:
+ case NSModalResponseStop: // Modal session was broken with stopModal
+ case NSModalResponseAbort: // Modal session was broken with abortModal
+ emit reject();
+ break;
+ default:
+ qCWarning(lcQpaDialogs) << "Unrecognized modal response" << response;
+ }
+ }
+
+ if (m_eventLoop)
+ m_eventLoop->exit(response);
+
+ // We can't re-use the same dialog for the next show() anyways,
+ // since the options may have changed, so get rid of it now.
+ [m_alert release];
+ m_alert = nil;
+}
+
+void QCocoaMessageDialog::hide()
+{
+ if (!m_alert)
+ return;
+
+ if (m_alert.window.visible) {
+ qCDebug(lcQpaDialogs) << "Hiding" << modality() << m_alert;
+
+ // Note: Just hiding or closing the NSAlert's NWindow here is not sufficient,
+ // as the dialog is running a modal event loop as well, which we need to end.
+
+ if (modality() == Qt::WindowModal) {
+ // Will call processResponse() synchronously
+ [m_alert.window.sheetParent endSheet:m_alert.window returnCode:kModalResponseDialogHidden];
+ } else {
+ if (NSApp.modalWindow == m_alert.window) {
+ // Will call processResponse() asynchronously
+ [NSApp stopModalWithCode:kModalResponseDialogHidden];
+ } else {
+ qCWarning(lcQpaDialogs, "Dialog is not top level modal window. Cannot hide.");
+ }
+ }
+ } else {
+ qCDebug(lcQpaDialogs) << "No need to hide already hidden" << m_alert;
+ }
+}
+
+Qt::WindowModality QCocoaMessageDialog::modality() const
+{
+ Q_ASSERT(m_alert && m_alert.window);
+ return m_alert.window.sheetParent ? Qt::WindowModal : Qt::ApplicationModal;
+}
+
+QT_END_NAMESPACE
diff --git a/src/plugins/platforms/cocoa/qcocoatheme.mm b/src/plugins/platforms/cocoa/qcocoatheme.mm
index 1d3fc2c1b1..69823b409b 100644
--- a/src/plugins/platforms/cocoa/qcocoatheme.mm
+++ b/src/plugins/platforms/cocoa/qcocoatheme.mm
@@ -31,6 +31,7 @@
#include "qcocoacolordialoghelper.h"
#include "qcocoafiledialoghelper.h"
#include "qcocoafontdialoghelper.h"
+#include "qcocoamessagedialog.h"
#include <CoreServices/CoreServices.h>
@@ -251,13 +252,15 @@ void QCocoaTheme::handleSystemThemeChange()
bool QCocoaTheme::usePlatformNativeDialog(DialogType dialogType) const
{
- if (dialogType == QPlatformTheme::FileDialog)
- return true;
- if (dialogType == QPlatformTheme::ColorDialog)
- return true;
- if (dialogType == QPlatformTheme::FontDialog)
+ switch (dialogType) {
+ case QPlatformTheme::FileDialog:
+ case QPlatformTheme::ColorDialog:
+ case QPlatformTheme::FontDialog:
+ case QPlatformTheme::MessageDialog:
return true;
- return false;
+ default:
+ return false;
+ }
}
QPlatformDialogHelper *QCocoaTheme::createPlatformDialogHelper(DialogType dialogType) const
@@ -269,6 +272,8 @@ QPlatformDialogHelper *QCocoaTheme::createPlatformDialogHelper(DialogType dialog
return new QCocoaColorDialogHelper();
case QPlatformTheme::FontDialog:
return new QCocoaFontDialogHelper();
+ case QPlatformTheme::MessageDialog:
+ return new QCocoaMessageDialog;
default:
return nullptr;
}
diff --git a/src/plugins/platforms/cocoa/qnsview.h b/src/plugins/platforms/cocoa/qnsview.h
index 1858c2660d..e41f5a7296 100644
--- a/src/plugins/platforms/cocoa/qnsview.h
+++ b/src/plugins/platforms/cocoa/qnsview.h
@@ -4,6 +4,8 @@
#ifndef QNSVIEW_H
#define QNSVIEW_H
+#include <AppKit/NSView.h>
+
#include <QtCore/private/qcore_mac_p.h>
QT_BEGIN_NAMESPACE
diff --git a/src/plugins/platforms/cocoa/qnswindow.h b/src/plugins/platforms/cocoa/qnswindow.h
index ae43f28e8e..f69e809133 100644
--- a/src/plugins/platforms/cocoa/qnswindow.h
+++ b/src/plugins/platforms/cocoa/qnswindow.h
@@ -8,6 +8,9 @@
#include <QPointer>
#include <QtCore/private/qcore_mac_p.h>
+#include <AppKit/NSWindow.h>
+#include <AppKit/NSPanel.h>
+
QT_FORWARD_DECLARE_CLASS(QCocoaWindow)
#if defined(__OBJC__)
diff --git a/tests/auto/widgets/dialogs/qmessagebox/tst_qmessagebox.cpp b/tests/auto/widgets/dialogs/qmessagebox/tst_qmessagebox.cpp
index 495a935255..808cd41b28 100644
--- a/tests/auto/widgets/dialogs/qmessagebox/tst_qmessagebox.cpp
+++ b/tests/auto/widgets/dialogs/qmessagebox/tst_qmessagebox.cpp
@@ -23,6 +23,9 @@ class tst_QMessageBox : public QObject
Q_OBJECT
private slots:
+ void initTestCase_data();
+ void init();
+
void sanityTest();
void defaultButton();
void escapeButton();
@@ -129,6 +132,22 @@ void ExecCloseHelper::timerEvent(QTimerEvent *te)
}
}
+void tst_QMessageBox::initTestCase_data()
+{
+ QTest::addColumn<bool>("useNativeDialog");
+ QTest::newRow("widget") << false;
+ if (const QPlatformTheme *theme = QGuiApplicationPrivate::platformTheme()) {
+ if (theme->usePlatformNativeDialog(QPlatformTheme::MessageDialog))
+ QTest::newRow("native") << true;
+ }
+}
+
+void tst_QMessageBox::init()
+{
+ QFETCH_GLOBAL(bool, useNativeDialog);
+ qApp->setAttribute(Qt::AA_DontUseNativeDialogs, !useNativeDialog);
+}
+
void tst_QMessageBox::cleanup()
{
QTRY_VERIFY(QApplication::topLevelWidgets().isEmpty()); // OS X requires TRY
@@ -484,6 +503,10 @@ void tst_QMessageBox::instanceSourceCompat()
void tst_QMessageBox::detailsText()
{
+ QFETCH_GLOBAL(bool, useNativeDialog);
+ if (useNativeDialog)
+ QSKIP("Native dialogs do not propagate expose events");
+
QMessageBox box;
QString text("This is the details text.");
box.setDetailedText(text);
@@ -497,6 +520,10 @@ void tst_QMessageBox::detailsText()
void tst_QMessageBox::detailsButtonText()
{
+ QFETCH_GLOBAL(bool, useNativeDialog);
+ if (useNativeDialog)
+ QSKIP("Native dialogs do not propagate expose events");
+
QMessageBox box;
box.setDetailedText("bla");
box.open();
@@ -518,6 +545,10 @@ void tst_QMessageBox::detailsButtonText()
void tst_QMessageBox::expandDetailsWithoutMoving() // QTBUG-32473
{
+ QFETCH_GLOBAL(bool, useNativeDialog);
+ if (useNativeDialog)
+ QSKIP("Native dialogs do not propagate expose events");
+
tst_ResizingMessageBox box;
box.setDetailedText("bla");
box.show();
@@ -617,6 +648,10 @@ Q_DECLARE_METATYPE(RoleSet);
void tst_QMessageBox::acceptedRejectedSignals()
{
+ QFETCH_GLOBAL(bool, useNativeDialog);
+ if (useNativeDialog)
+ QSKIP("Native dialogs do not propagate expose events");
+
QMessageBox messageBox(QMessageBox::Information, "Test window", "Test text");
QFETCH(ButtonsCreator, buttonsCreator);