diff options
Diffstat (limited to 'src/plugins/platforms/cocoa/qcocoamessagedialog.mm')
-rw-r--r-- | src/plugins/platforms/cocoa/qcocoamessagedialog.mm | 167 |
1 files changed, 135 insertions, 32 deletions
diff --git a/src/plugins/platforms/cocoa/qcocoamessagedialog.mm b/src/plugins/platforms/cocoa/qcocoamessagedialog.mm index d5d9469832..f786fc65c4 100644 --- a/src/plugins/platforms/cocoa/qcocoamessagedialog.mm +++ b/src/plugins/platforms/cocoa/qcocoamessagedialog.mm @@ -5,6 +5,7 @@ #include "qcocoawindow.h" #include "qcocoahelpers.h" +#include "qcocoaeventdispatcher.h" #include <QtCore/qmetaobject.h> #include <QtCore/qscopedvaluerollback.h> @@ -46,6 +47,16 @@ static QString toPlainText(const QString &text) return textDocument.toPlainText(); } +static NSControlStateValue controlStateFor(Qt::CheckState state) +{ + switch (state) { + case Qt::Checked: return NSControlStateValueOn; + case Qt::Unchecked: return NSControlStateValueOff; + case Qt::PartiallyChecked: return NSControlStateValueMixed; + } + Q_UNREACHABLE(); +} + /* Called from QDialogPrivate::setNativeDialogVisible() when the message box is ready to be shown. @@ -80,14 +91,25 @@ bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality w if (!options()) return false; + // NSAlert doesn't have a section for detailed text + if (!options()->detailedText().isEmpty()) { + qCWarning(lcQpaDialogs, "Message box contains detailed text"); + return false; + } + + if (Qt::mightBeRichText(options()->text()) || + Qt::mightBeRichText(options()->informativeText())) { + // Let's fallback to non-native message box, + // we only have plain NSString/text in NSAlert. + qCDebug(lcQpaDialogs, "Message box contains text in rich text format"); + 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; + const QString text = toPlainText(options()->text()); m_alert.messageText = text.toNSString(); m_alert.informativeText = toPlainText(options()->informativeText()).toNSString(); @@ -115,8 +137,8 @@ bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality w break; } - bool defaultButtonAdded = false; - bool cancelButtonAdded = false; + auto defaultButton = options()->defaultButton(); + auto escapeButton = options()->escapeButton(); const auto addButton = [&](auto title, auto tag, auto role) { title = QPlatformTheme::removeMnemonics(title); @@ -126,20 +148,29 @@ bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality w // 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. + // button). If an explicit default or escape button has been set, we respect these, + // and otherwise we fall back to role-based default and escape buttons. + + qCDebug(lcQpaDialogs).verbosity(0) << "Adding button" << title << "with" << role; + + if (!defaultButton && role == AcceptRole) + defaultButton = tag; - if (role == AcceptRole && !defaultButtonAdded) { + if (tag == defaultButton) button.keyEquivalent = @"\r"; - defaultButtonAdded = true; - } else if (role == RejectRole && !cancelButtonAdded) { + else if ([button.keyEquivalent isEqualToString:@"\r"]) + button.keyEquivalent = @""; + + if (!escapeButton && role == RejectRole) + escapeButton = tag; + + // Don't override default button with escape button, to match AppKit default + if (tag == escapeButton && ![button.keyEquivalent isEqualToString:@"\r"]) button.keyEquivalent = @"\e"; - cancelButtonAdded = true; - } + else if ([button.keyEquivalent isEqualToString:@"\e"]) + button.keyEquivalent = @""; - if (@available(macOS 11, *)) - button.hasDestructiveAction = role == DestructiveRole; + 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), @@ -156,31 +187,77 @@ bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality w button.tag = tag; }; + // Resolve all dialog buttons from the options, both standard and custom + + struct Button { QString title; int identifier; ButtonRole role; }; + std::vector<Button> buttons; + const auto *platformTheme = QGuiApplicationPrivate::platformTheme(); if (auto standardButtons = options()->standardButtons()) { - for (int standardButton = FirstButton; standardButton < LastButton; standardButton <<= 1) { + for (int standardButton = FirstButton; standardButton <= LastButton; standardButton <<= 1) { if (standardButtons & standardButton) { auto title = platformTheme->standardButtonText(standardButton); - addButton(title, standardButton, buttonRole(StandardButton(standardButton))); + buttons.push_back({ + title, standardButton, buttonRole(StandardButton(standardButton)) + }); } } } - const auto customButtons = options()->customButtons(); for (auto customButton : customButtons) - addButton(customButton.label, customButton.id, customButton.role); + buttons.push_back({customButton.label, customButton.id, customButton.role}); + + // Sort them according to the QPlatformDialogHelper::ButtonLayout for macOS + + // The ButtonLayout adds one additional role, AlternateRole, which is used + // for any AcceptRole beyond the first one, and should be ordered before the + // AcceptRole. Set this up by fixing the roles up front. + bool seenAccept = false; + for (auto &button : buttons) { + if (button.role == AcceptRole) { + if (!seenAccept) + seenAccept = true; + else + button.role = AlternateRole; + } + } + + std::vector<Button> orderedButtons; + const int *layoutEntry = buttonLayout(Qt::Horizontal, ButtonLayout::MacLayout); + while (*layoutEntry != QPlatformDialogHelper::EOL) { + const auto role = ButtonRole(*layoutEntry & ~ButtonRole::Reverse); + const bool reverse = *layoutEntry & ButtonRole::Reverse; + auto addButton = [&](const Button &button) { + if (button.role == role) + orderedButtons.push_back(button); + }; - // 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); + if (reverse) + std::for_each(std::crbegin(buttons), std::crend(buttons), addButton); + else + std::for_each(std::cbegin(buttons), std::cend(buttons), addButton); + + ++layoutEntry; } - m_alert.showsSuppressionButton = options()->supressionCheckBoxEnabled(); + // Add them to the alert in reverse order, since buttons are added right to left + for (auto button = orderedButtons.crbegin(); button != orderedButtons.crend(); ++button) + addButton(button->title, button->identifier, button->role); + + // If we didn't find a an explicit or implicit default button above + // we restore the AppKit behavior of making the first button default. + if (!defaultButton) + m_alert.buttons.firstObject.keyEquivalent = @"\r"; + + if (auto checkBoxLabel = options()->checkBoxLabel(); !checkBoxLabel.isNull()) { + checkBoxLabel = QPlatformTheme::removeMnemonics(checkBoxLabel); + m_alert.suppressionButton.title = checkBoxLabel.toNSString(); + auto state = options()->checkBoxState(); + m_alert.suppressionButton.allowsMixedState = state == Qt::PartiallyChecked; + m_alert.suppressionButton.state = controlStateFor(state); + m_alert.showsSuppressionButton = YES; + } qCDebug(lcQpaDialogs) << "Showing" << m_alert; @@ -200,9 +277,10 @@ bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality w // 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) { + if (m_alert && !m_alert.window.visible) { qCDebug(lcQpaDialogs) << "Running deferred modal" << m_alert; - processResponse([m_alert runModal]); + QCocoaEventDispatcher::clearCurrentThreadCocoaEventDispatcherInterruptFlag(); + processResponse(runModal()); } }); } @@ -210,6 +288,20 @@ bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality w return true; } +// We shouldn't get NSModalResponseContinue as a response from NSAlert::runModal, +// and processResponse must not be called with that value (if we are there, it's +// too late to do anything about it. +// However, as QTBUG-114546 shows, there are scenarios where we might get that +// response anyway. We interpret it to keep the modal loop running, and we only +// return if we got something else to pass to processResponse. +NSModalResponse QCocoaMessageDialog::runModal() const +{ + NSModalResponse response = NSModalResponseContinue; + while (response == NSModalResponseContinue) + response = [m_alert runModal]; + return response; +} + void QCocoaMessageDialog::exec() { Q_ASSERT(m_alert); @@ -221,13 +313,24 @@ void QCocoaMessageDialog::exec() m_eventLoop->exec(QEventLoop::DialogExec); } else { qCDebug(lcQpaDialogs) << "Running modal" << m_alert; - processResponse([m_alert runModal]); + QCocoaEventDispatcher::clearCurrentThreadCocoaEventDispatcherInterruptFlag(); + processResponse(runModal()); } } // Custom modal response code to record that the dialog was hidden by us static const NSInteger kModalResponseDialogHidden = NSAlertThirdButtonReturn + 1; +static Qt::CheckState checkStateFor(NSControlStateValue state) +{ + switch (state) { + case NSControlStateValueOn: return Qt::Checked; + case NSControlStateValueOff: return Qt::Unchecked; + case NSControlStateValueMixed: return Qt::PartiallyChecked; + } + Q_UNREACHABLE(); +} + void QCocoaMessageDialog::processResponse(NSModalResponse response) { qCDebug(lcQpaDialogs) << "Processing response" << response << "for" << m_alert; @@ -239,7 +342,7 @@ void QCocoaMessageDialog::processResponse(NSModalResponse response) [alert autorelease]; if (alert.showsSuppressionButton) - emit supressionCheckBoxChanged(alert.suppressionButton.state == NSControlStateValueOn); + emit checkBoxStateChanged(checkStateFor(alert.suppressionButton.state)); if (response >= NSAlertFirstButtonReturn) { // Safe range for user-defined modal responses |