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