Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qcocoamessagedialog.mm
Go to the documentation of this file.
1// Copyright (C) 2022 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3// Qt-Security score:significant reason:default
4
6
7#include "qcocoawindow.h"
10
11#include <QtCore/qmetaobject.h>
12#include <QtCore/qscopedvaluerollback.h>
13#include <QtCore/qtimer.h>
14
15#include <QtGui/qtextdocument.h>
16#include <QtGui/private/qguiapplication_p.h>
17#include <QtGui/private/qcoregraphics_p.h>
18#include <QtGui/qpa/qplatformtheme.h>
19
20#include <AppKit/NSAlert.h>
21#include <AppKit/NSButton.h>
22
23QT_USE_NAMESPACE
24
25using namespace Qt::StringLiterals;
26
28
30{
31 hide();
32 [m_alert release];
33}
34
35static QString toPlainText(const QString &text)
36{
37 // FIXME: QMessageDialog supports Qt::TextFormat, which
38 // nowadays includes Qt::MarkdownText, but we don't have
39 // the machinery to deal with that yet. We should as a
40 // start plumb the dialog's text format to the platform
41 // via the dialog options.
42
43 if (!Qt::mightBeRichText(text))
44 return text;
45
46 QTextDocument textDocument;
47 textDocument.setHtml(text);
48 return textDocument.toPlainText();
49}
50
51static NSControlStateValue controlStateFor(Qt::CheckState state)
52{
53 switch (state) {
54 case Qt::Checked: return NSControlStateValueOn;
55 case Qt::Unchecked: return NSControlStateValueOff;
56 case Qt::PartiallyChecked: return NSControlStateValueMixed;
57 }
58 Q_UNREACHABLE();
59}
60
61/*
62 Called from QDialogPrivate::setNativeDialogVisible() when the message box
63 is ready to be shown.
64
65 At this point the options() will reflect the specific dialog shown.
66
67 Returns true if the helper could successfully show the dialog, or
68 false if the cross platform fallback dialog should be used instead.
69*/
70bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent)
71{
72 Q_UNUSED(windowFlags);
73
74 qCDebug(lcQpaDialogs) << "Asked to show" << windowModality << "dialog with parent" << parent;
75
76 if (m_alert.window.visible) {
77 qCDebug(lcQpaDialogs) << "Dialog already visible, ignoring request to show";
78 return true; // But we don't want to show the fallback dialog instead
79 }
80
81 // We can only do application and window modal dialogs
82 if (windowModality == Qt::NonModal)
83 return false;
84
85 // And only window modal if we have a parent
86 if (windowModality == Qt::WindowModal && (!parent || !parent->handle())) {
87 qCWarning(lcQpaDialogs, "Cannot run window modal dialog without parent window");
88 return false;
89 }
90
91 // Tahoe has issues with window-modal alert buttons not responding to mouse
92 if (windowModality == Qt::WindowModal
93 && QOperatingSystemVersion::current() >= QOperatingSystemVersion::MacOSTahoe)
94 return false;
95
96 // And without options we don't know what to show
97 if (!options())
98 return false;
99
100 // NSAlert doesn't have a section for detailed text
101 if (!options()->detailedText().isEmpty()) {
102 qCWarning(lcQpaDialogs, "Message box contains detailed text");
103 return false;
104 }
105
106 if (Qt::mightBeRichText(options()->text()) ||
107 Qt::mightBeRichText(options()->informativeText())) {
108 // Let's fallback to non-native message box,
109 // we only have plain NSString/text in NSAlert.
110 qCDebug(lcQpaDialogs, "Message box contains text in rich text format");
111 return false;
112 }
113
114 Q_ASSERT(!m_alert);
115 m_alert = [NSAlert new];
116 m_alert.window.title = options()->windowTitle().toNSString();
117
118 const QString text = toPlainText(options()->text());
119 m_alert.messageText = text.toNSString();
120 m_alert.informativeText = toPlainText(options()->informativeText()).toNSString();
121
122 switch (options()->standardIcon()) {
123 case QMessageDialogOptions::NoIcon: {
124 // We only reflect the pixmap icon if the standard icon is unset,
125 // as setting a standard icon will also set a corresponding pixmap
126 // icon, which we don't want since it conflicts with the platform.
127 // If the user has set an explicit pixmap icon however, the standard
128 // icon will be NoIcon, so we're good.
129 QPixmap iconPixmap = options()->iconPixmap();
130 if (!iconPixmap.isNull())
131 m_alert.icon = [NSImage imageFromQImage:iconPixmap.toImage()];
132 break;
133 }
134 case QMessageDialogOptions::Information:
135 case QMessageDialogOptions::Question:
136 [m_alert setAlertStyle:NSAlertStyleInformational];
137 break;
138 case QMessageDialogOptions::Warning:
139 [m_alert setAlertStyle:NSAlertStyleWarning];
140 break;
141 case QMessageDialogOptions::Critical:
142 [m_alert setAlertStyle:NSAlertStyleCritical];
143 break;
144 }
145
146 auto defaultButton = options()->defaultButton();
147 auto escapeButton = options()->escapeButton();
148
149 const auto addButton = [&](auto title, auto tag, auto role) {
150 title = QPlatformTheme::removeMnemonics(title);
151 NSButton *button = [m_alert addButtonWithTitle:title.toNSString()];
152
153 // Calling addButtonWithTitle places buttons starting at the right side/top of the alert
154 // and going toward the left/bottom. By default, the first button has a key equivalent of
155 // Return, any button with a title of "Cancel" has a key equivalent of Escape, and any button
156 // with the title "Don't Save" has a key equivalent of Command-D (but only if it's not the first
157 // button). If an explicit default or escape button has been set, we respect these,
158 // and otherwise we fall back to role-based default and escape buttons.
159
160 qCDebug(lcQpaDialogs).verbosity(0) << "Adding button" << title << "with" << role;
161
162 if (!defaultButton && role == AcceptRole)
163 defaultButton = tag;
164
165 if (tag == defaultButton)
166 button.keyEquivalent = @"\r";
167 else if ([button.keyEquivalent isEqualToString:@"\r"])
168 button.keyEquivalent = @"";
169
170 if (!escapeButton && role == RejectRole)
171 escapeButton = tag;
172
173 // Don't override default button with escape button, to match AppKit default
174 if (tag == escapeButton && ![button.keyEquivalent isEqualToString:@"\r"])
175 button.keyEquivalent = @"\e";
176 else if ([button.keyEquivalent isEqualToString:@"\e"])
177 button.keyEquivalent = @"";
178
179 button.hasDestructiveAction = role == DestructiveRole;
180
181 // The NSModalResponse of showing an NSAlert normally depends on the order of the
182 // button that was clicked, starting from the right with NSAlertFirstButtonReturn (1000),
183 // NSAlertSecondButtonReturn (1001), NSAlertThirdButtonReturn (1002), and after that
184 // NSAlertThirdButtonReturn + n. The response can also be customized per button via its
185 // tag, which, following the above logic, can include any positive value from 1000 and up.
186 // In addition the system reserves the values from -1000 and down for its own modal responses,
187 // such as NSModalResponseStop, NSModalResponseAbort, and NSModalResponseContinue.
188 // Luckily for us, the QPlatformDialogHelper::StandardButton enum values all fall within
189 // the positive range, so we can use the standard button value as the tag directly.
190 // The same applies to the custom button IDs, as these are generated in sequence after
191 // the QPlatformDialogHelper::LastButton.
192 Q_ASSERT(tag >= NSAlertFirstButtonReturn);
193 button.tag = tag;
194 };
195
196 // Resolve all dialog buttons from the options, both standard and custom
197
198 struct Button { QString title; int identifier; ButtonRole role; };
199 std::vector<Button> buttons;
200
201 const auto *platformTheme = QGuiApplicationPrivate::platformTheme();
202 if (auto standardButtons = options()->standardButtons()) {
203 for (int standardButton = FirstButton; standardButton <= LastButton; standardButton <<= 1) {
204 if (standardButtons & standardButton) {
205 auto title = platformTheme->standardButtonText(standardButton);
206 buttons.push_back({
207 title, standardButton, buttonRole(StandardButton(standardButton))
208 });
209 }
210 }
211 }
212 const auto customButtons = options()->customButtons();
213 for (auto customButton : customButtons)
214 buttons.push_back({customButton.label, customButton.id, customButton.role});
215
216 // Sort them according to the QPlatformDialogHelper::ButtonLayout for macOS
217
218 // The ButtonLayout adds one additional role, AlternateRole, which is used
219 // for any AcceptRole beyond the first one, and should be ordered before the
220 // AcceptRole. Set this up by fixing the roles up front.
221 bool seenAccept = false;
222 for (auto &button : buttons) {
223 if (button.role == AcceptRole) {
224 if (!seenAccept)
225 seenAccept = true;
226 else
227 button.role = AlternateRole;
228 }
229 }
230
231 std::vector<Button> orderedButtons;
232 const int *layoutEntry = buttonLayout(Qt::Horizontal, ButtonLayout::MacLayout);
233 while (*layoutEntry != QPlatformDialogHelper::EOL) {
234 const auto role = ButtonRole(*layoutEntry & ~ButtonRole::Reverse);
235 const bool reverse = *layoutEntry & ButtonRole::Reverse;
236
237 auto addButton = [&](const Button &button) {
238 if (button.role == role)
239 orderedButtons.push_back(button);
240 };
241
242 if (reverse)
243 std::for_each(std::crbegin(buttons), std::crend(buttons), addButton);
244 else
245 std::for_each(std::cbegin(buttons), std::cend(buttons), addButton);
246
247 ++layoutEntry;
248 }
249
250 // Add them to the alert in reverse order, since buttons are added right to left
251 for (auto button = orderedButtons.crbegin(); button != orderedButtons.crend(); ++button)
252 addButton(button->title, button->identifier, button->role);
253
254 // If we didn't find a an explicit or implicit default button above
255 // we restore the AppKit behavior of making the first button default.
256 if (!defaultButton)
257 m_alert.buttons.firstObject.keyEquivalent = @"\r";
258
259 if (auto checkBoxLabel = options()->checkBoxLabel(); !checkBoxLabel.isNull()) {
260 checkBoxLabel = QPlatformTheme::removeMnemonics(checkBoxLabel);
261 m_alert.suppressionButton.title = checkBoxLabel.toNSString();
262 auto state = options()->checkBoxState();
263 m_alert.suppressionButton.allowsMixedState = state == Qt::PartiallyChecked;
264 m_alert.suppressionButton.state = controlStateFor(state);
265 m_alert.showsSuppressionButton = YES;
266 }
267
268 qCDebug(lcQpaDialogs) << "Showing" << m_alert;
269
270 if (windowModality == Qt::WindowModal) {
271 auto *cocoaWindow = static_cast<QCocoaWindow*>(parent->handle());
272 [m_alert beginSheetModalForWindow:cocoaWindow->nativeWindow()
273 completionHandler:^(NSModalResponse response) {
274 processResponse(response);
275 }
276 ];
277 } else {
278 // The dialog is application modal, so we need to call runModal,
279 // but we can't call it here as the nativeDialogInUse state of QDialog
280 // depends on the result of show(), and we can't rely on doing it
281 // in exec(), as we can't guarantee that the user will call exec()
282 // after showing the dialog. As a workaround, we call it from exec(),
283 // but also make sure that if the user returns to the main runloop
284 // we'll run the modal dialog from there.
285 QTimer::singleShot(0, this, [this]{
286 if (m_alert && !m_alert.window.visible) {
287 qCDebug(lcQpaDialogs) << "Running deferred modal" << m_alert;
288 QCocoaEventDispatcher::clearCurrentThreadCocoaEventDispatcherInterruptFlag();
289 processResponse(runModal());
290 }
291 });
292 }
293
294 return true;
295}
296
297// We shouldn't get NSModalResponseContinue as a response from NSAlert::runModal,
298// and processResponse must not be called with that value (if we are there, it's
299// too late to do anything about it.
300// However, as QTBUG-114546 shows, there are scenarios where we might get that
301// response anyway. We interpret it to keep the modal loop running, and we only
302// return if we got something else to pass to processResponse.
303NSModalResponse QCocoaMessageDialog::runModal() const
304{
305 NSModalResponse response = NSModalResponseContinue;
306 while (response == NSModalResponseContinue)
307 response = [m_alert runModal];
308 return response;
309}
310
312{
313 Q_ASSERT(m_alert);
314
315 if (modality() == Qt::WindowModal) {
316 qCDebug(lcQpaDialogs) << "Running local event loop for window modal" << m_alert;
317 QEventLoop eventLoop;
318 QScopedValueRollback updateGuard(m_eventLoop, &eventLoop);
319 m_eventLoop->exec(QEventLoop::DialogExec);
320 } else {
321 qCDebug(lcQpaDialogs) << "Running modal" << m_alert;
323 processResponse(runModal());
324 }
325}
326
327// Custom modal response code to record that the dialog was hidden by us
328static const NSInteger kModalResponseDialogHidden = NSAlertThirdButtonReturn + 1;
329
330static Qt::CheckState checkStateFor(NSControlStateValue state)
331{
332 switch (state) {
333 case NSControlStateValueOn: return Qt::Checked;
334 case NSControlStateValueOff: return Qt::Unchecked;
335 case NSControlStateValueMixed: return Qt::PartiallyChecked;
336 }
337 Q_UNREACHABLE();
338}
339
340void QCocoaMessageDialog::processResponse(NSModalResponse response)
341{
342 qCDebug(lcQpaDialogs) << "Processing response" << response << "for" << m_alert;
343
344 // We can't re-use the same dialog for the next show() anyways,
345 // since the options may have changed, so get rid of it now,
346 // before we emit anything that might recurse back to hide/show/etc.
347 auto alert = std::exchange(m_alert, nil);
348 [alert autorelease];
349
350 if (alert.showsSuppressionButton)
351 emit checkBoxStateChanged(checkStateFor(alert.suppressionButton.state));
352
353 if (response >= NSAlertFirstButtonReturn) {
354 // Safe range for user-defined modal responses
355 if (response == kModalResponseDialogHidden) {
356 // Dialog was explicitly hidden by us, so nothing to report
357 qCDebug(lcQpaDialogs) << "Dialog was hidden; ignoring response";
358 } else {
359 // Dialog buttons
360 if (response <= StandardButton::LastButton) {
361 Q_ASSERT(response >= StandardButton::FirstButton);
362 auto standardButton = StandardButton(response);
363 emit clicked(standardButton, buttonRole(standardButton));
364 } else {
365 auto *customButton = options()->customButton(response);
366 Q_ASSERT(customButton);
367 emit clicked(StandardButton(customButton->id), customButton->role);
368 }
369 }
370 } else {
371 // We have to consider NSModalResponses beyond the ones specific to
372 // the alert buttons as the alert may be canceled programmatically.
373
374 switch (response) {
375 case NSModalResponseContinue:
376 // Modal session is continuing (returned by runModalSession: only)
377 Q_UNREACHABLE();
378 case NSModalResponseOK:
379 emit accept();
380 break;
381 case NSModalResponseCancel:
382 case NSModalResponseStop: // Modal session was broken with stopModal
383 case NSModalResponseAbort: // Modal session was broken with abortModal
384 emit reject();
385 break;
386 default:
387 qCWarning(lcQpaDialogs) << "Unrecognized modal response" << response;
388 }
389 }
390
391 if (m_eventLoop)
392 m_eventLoop->exit(response);
393}
394
396{
397 if (!m_alert)
398 return;
399
400 if (m_alert.window.visible) {
401 qCDebug(lcQpaDialogs) << "Hiding" << modality() << m_alert;
402
403 // Note: Just hiding or closing the NSAlert's NWindow here is not sufficient,
404 // as the dialog is running a modal event loop as well, which we need to end.
405
406 if (modality() == Qt::WindowModal) {
407 // Will call processResponse() synchronously
408 [m_alert.window.sheetParent endSheet:m_alert.window returnCode:kModalResponseDialogHidden];
409 } else {
410 if (NSApp.modalWindow == m_alert.window) {
411 // Will call processResponse() asynchronously
412 [NSApp stopModalWithCode:kModalResponseDialogHidden];
413 } else {
414 qCWarning(lcQpaDialogs, "Dialog is not top level modal window. Cannot hide.");
415 }
416 }
417 } else {
418 qCDebug(lcQpaDialogs) << "No need to hide already hidden" << m_alert;
419 auto alert = std::exchange(m_alert, nil);
420 [alert autorelease];
421 }
422}
423
424Qt::WindowModality QCocoaMessageDialog::modality() const
425{
426 Q_ASSERT(m_alert && m_alert.window);
427 return m_alert.window.sheetParent ? Qt::WindowModal : Qt::ApplicationModal;
428}
429
430QT_END_NAMESPACE
static void clearCurrentThreadCocoaEventDispatcherInterruptFlag()
bool show(Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent) override
NSInteger NSModalResponse
static const NSInteger kModalResponseDialogHidden
static NSControlStateValue controlStateFor(Qt::CheckState state)
static QString toPlainText(const QString &text)
static Qt::CheckState checkStateFor(NSControlStateValue state)
long NSInteger