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>
This commit is contained in:
Tor Arne Vestbø 2022-10-26 19:32:02 +02:00
parent da0587c43a
commit a47c7a9826
9 changed files with 395 additions and 6 deletions

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
switch (dialogType) {
case QPlatformTheme::FileDialog:
case QPlatformTheme::ColorDialog:
case QPlatformTheme::FontDialog:
case QPlatformTheme::MessageDialog:
return true;
if (dialogType == QPlatformTheme::ColorDialog)
return true;
if (dialogType == QPlatformTheme::FontDialog)
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;
}

View File

@ -4,6 +4,8 @@
#ifndef QNSVIEW_H
#define QNSVIEW_H
#include <AppKit/NSView.h>
#include <QtCore/private/qcore_mac_p.h>
QT_BEGIN_NAMESPACE

View File

@ -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__)

View File

@ -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);