Qt
Internal/Contributor docs for the Qt SDK. <b>Note:</b> These are NOT official API docs; those are found <a href='https://doc.qt.io/'>here</a>.
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
5
6#include "qcocoawindow.h"
7#include "qcocoahelpers.h"
9
10#include <QtCore/qmetaobject.h>
11#include <QtCore/qscopedvaluerollback.h>
12#include <QtCore/qtimer.h>
13
14#include <QtGui/qtextdocument.h>
15#include <QtGui/private/qguiapplication_p.h>
16#include <QtGui/private/qcoregraphics_p.h>
17#include <QtGui/qpa/qplatformtheme.h>
18
19#include <AppKit/NSAlert.h>
20#include <AppKit/NSButton.h>
21
23
24using namespace Qt::StringLiterals;
25
27
33
35{
36 // FIXME: QMessageDialog supports Qt::TextFormat, which
37 // nowadays includes Qt::MarkdownText, but we don't have
38 // the machinery to deal with that yet. We should as a
39 // start plumb the dialog's text format to the platform
40 // via the dialog options.
41
43 return text;
44
45 QTextDocument textDocument;
46 textDocument.setHtml(text);
47 return textDocument.toPlainText();
48}
49
50static NSControlStateValue controlStateFor(Qt::CheckState state)
51{
52 switch (state) {
53 case Qt::Checked: return NSControlStateValueOn;
54 case Qt::Unchecked: return NSControlStateValueOff;
55 case Qt::PartiallyChecked: return NSControlStateValueMixed;
56 }
57 Q_UNREACHABLE();
58}
59
60/*
61 Called from QDialogPrivate::setNativeDialogVisible() when the message box
62 is ready to be shown.
63
64 At this point the options() will reflect the specific dialog shown.
65
66 Returns true if the helper could successfully show the dialog, or
67 false if the cross platform fallback dialog should be used instead.
68*/
69bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent)
70{
71 Q_UNUSED(windowFlags);
72
73 qCDebug(lcQpaDialogs) << "Asked to show" << windowModality << "dialog with parent" << parent;
74
75 if (m_alert.window.visible) {
76 qCDebug(lcQpaDialogs) << "Dialog already visible, ignoring request to show";
77 return true; // But we don't want to show the fallback dialog instead
78 }
79
80 // We can only do application and window modal dialogs
81 if (windowModality == Qt::NonModal)
82 return false;
83
84 // And only window modal if we have a parent
85 if (windowModality == Qt::WindowModal && (!parent || !parent->handle())) {
86 qCWarning(lcQpaDialogs, "Cannot run window modal dialog without parent window");
87 return false;
88 }
89
90 // And without options we don't know what to show
91 if (!options())
92 return false;
93
94 // NSAlert doesn't have a section for detailed text
95 if (!options()->detailedText().isEmpty()) {
96 qCWarning(lcQpaDialogs, "Message box contains detailed text");
97 return false;
98 }
99
100 if (Qt::mightBeRichText(options()->text()) ||
101 Qt::mightBeRichText(options()->informativeText())) {
102 // Let's fallback to non-native message box,
103 // we only have plain NSString/text in NSAlert.
104 qCDebug(lcQpaDialogs, "Message box contains text in rich text format");
105 return false;
106 }
107
108 Q_ASSERT(!m_alert);
109 m_alert = [NSAlert new];
110 m_alert.window.title = options()->windowTitle().toNSString();
111
112 const QString text = toPlainText(options()->text());
113 m_alert.messageText = text.toNSString();
114 m_alert.informativeText = toPlainText(options()->informativeText()).toNSString();
115
116 switch (options()->standardIcon()) {
118 // We only reflect the pixmap icon if the standard icon is unset,
119 // as setting a standard icon will also set a corresponding pixmap
120 // icon, which we don't want since it conflicts with the platform.
121 // If the user has set an explicit pixmap icon however, the standard
122 // icon will be NoIcon, so we're good.
123 QPixmap iconPixmap = options()->iconPixmap();
124 if (!iconPixmap.isNull())
125 m_alert.icon = [NSImage imageFromQImage:iconPixmap.toImage()];
126 break;
127 }
130 [m_alert setAlertStyle:NSAlertStyleInformational];
131 break;
133 [m_alert setAlertStyle:NSAlertStyleWarning];
134 break;
136 [m_alert setAlertStyle:NSAlertStyleCritical];
137 break;
138 }
139
140 auto defaultButton = options()->defaultButton();
141 auto escapeButton = options()->escapeButton();
142
143 const auto addButton = [&](auto title, auto tag, auto role) {
145 NSButton *button = [m_alert addButtonWithTitle:title.toNSString()];
146
147 // Calling addButtonWithTitle places buttons starting at the right side/top of the alert
148 // and going toward the left/bottom. By default, the first button has a key equivalent of
149 // Return, any button with a title of "Cancel" has a key equivalent of Escape, and any button
150 // with the title "Don't Save" has a key equivalent of Command-D (but only if it's not the first
151 // button). If an explicit default or escape button has been set, we respect these,
152 // and otherwise we fall back to role-based default and escape buttons.
153
154 qCDebug(lcQpaDialogs).verbosity(0) << "Adding button" << title << "with" << role;
155
156 if (!defaultButton && role == AcceptRole)
157 defaultButton = tag;
158
159 if (tag == defaultButton)
160 button.keyEquivalent = @"\r";
161 else if ([button.keyEquivalent isEqualToString:@"\r"])
162 button.keyEquivalent = @"";
163
164 if (!escapeButton && role == RejectRole)
165 escapeButton = tag;
166
167 // Don't override default button with escape button, to match AppKit default
168 if (tag == escapeButton && ![button.keyEquivalent isEqualToString:@"\r"])
169 button.keyEquivalent = @"\e";
170 else if ([button.keyEquivalent isEqualToString:@"\e"])
171 button.keyEquivalent = @"";
172
173 if (@available(macOS 11, *))
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) {
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
static void clearCurrentThreadCocoaEventDispatcherInterruptFlag()
bool show(Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent) override
\inmodule QtCore
Definition qeventloop.h:16
int exec(ProcessEventsFlags flags=AllEvents)
Enters the main event loop and waits until exit() is called.
void exit(int returnCode=0)
Tells the event loop to exit with a return code.
static QPlatformTheme * platformTheme()
const CustomButton * customButton(int id)
const QList< CustomButton > & customButtons()
QObject * parent() const
Returns a pointer to the parent object.
Definition qobject.h:346
Returns a copy of the pixmap that is transformed using the given transformation transform and transfo...
Definition qpixmap.h:27
QImage toImage() const
Converts the pixmap to a QImage.
Definition qpixmap.cpp:408
bool isNull() const
Returns true if this is a null pixmap; otherwise returns false.
Definition qpixmap.cpp:456
static ButtonRole buttonRole(StandardButton button)
void checkBoxStateChanged(Qt::CheckState state)
const QSharedPointer< QMessageDialogOptions > & options() const
void clicked(QPlatformDialogHelper::StandardButton button, QPlatformDialogHelper::ButtonRole role)
static QString removeMnemonics(const QString &original)
\macro QT_RESTRICTED_CAST_FROM_ASCII
Definition qstring.h:129
void push_back(QChar c)
This is an overloaded member function, provided for convenience. It differs from the above function o...
Definition qstring.h:957
\reentrant \inmodule QtGui
void setHtml(const QString &html)
Replaces the entire contents of the document with the given HTML-formatted text in the html string.
QString toPlainText() const
Returns the plain text contained in the document.
bool singleShot
whether the timer is a single-shot timer
Definition qtimer.h:22
\inmodule QtGui
Definition qwindow.h:63
QString text
QPushButton * button
[2]
else opt state
[0]
Combined button and popup list for selecting options.
CheckState
@ Unchecked
@ Checked
@ PartiallyChecked
WindowModality
@ NonModal
@ WindowModal
@ ApplicationModal
@ Horizontal
Definition qnamespace.h:99
Q_GUI_EXPORT bool mightBeRichText(QAnyStringView)
Returns true if the string text is likely to be rich text; otherwise returns false.
long NSInteger
NSInteger NSModalResponse
static NSControlStateValue controlStateFor(Qt::CheckState state)
static QString toPlainText(const QString &text)
static const NSInteger kModalResponseDialogHidden
static Qt::CheckState checkStateFor(NSControlStateValue state)
AudioChannelLayoutTag tag
#define qCWarning(category,...)
#define qCDebug(category,...)
Button
#define Q_ASSERT(cond)
Definition qrandom.cpp:47
#define emit
#define Q_UNUSED(x)
QString title
[35]
sem release()