Qt 6.x
The Qt SDK
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
29{
30 hide();
31 [m_alert release];
32}
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
95 Q_ASSERT(!m_alert);
96 m_alert = [NSAlert new];
97 m_alert.window.title = options()->windowTitle().toNSString();
98
100 QString details = toPlainText(options()->detailedText());
101 if (!details.isEmpty())
102 text += u"\n\n"_s + details;
103 m_alert.messageText = text.toNSString();
104 m_alert.informativeText = toPlainText(options()->informativeText()).toNSString();
105
106 switch (options()->standardIcon()) {
108 // We only reflect the pixmap icon if the standard icon is unset,
109 // as setting a standard icon will also set a corresponding pixmap
110 // icon, which we don't want since it conflicts with the platform.
111 // If the user has set an explicit pixmap icon however, the standard
112 // icon will be NoIcon, so we're good.
113 QPixmap iconPixmap = options()->iconPixmap();
114 if (!iconPixmap.isNull())
115 m_alert.icon = [NSImage imageFromQImage:iconPixmap.toImage()];
116 break;
117 }
120 [m_alert setAlertStyle:NSAlertStyleInformational];
121 break;
123 [m_alert setAlertStyle:NSAlertStyleWarning];
124 break;
126 [m_alert setAlertStyle:NSAlertStyleCritical];
127 break;
128 }
129
130 bool defaultButtonAdded = false;
131 bool cancelButtonAdded = false;
132
133 const auto addButton = [&](auto title, auto tag, auto role) {
135 NSButton *button = [m_alert addButtonWithTitle:title.toNSString()];
136
137 // Calling addButtonWithTitle places buttons starting at the right side/top of the alert
138 // and going toward the left/bottom. By default, the first button has a key equivalent of
139 // Return, any button with a title of "Cancel" has a key equivalent of Escape, and any button
140 // with the title "Don't Save" has a key equivalent of Command-D (but only if it's not the first
141 // button). Unfortunately QMessageBox does not currently plumb setDefaultButton/setEscapeButton
142 // through the dialog options, so we can't forward this information directly. The closest we
143 // can get right now is to use the role to set the button's key equivalent.
144
145 if (role == AcceptRole && !defaultButtonAdded) {
146 button.keyEquivalent = @"\r";
147 defaultButtonAdded = true;
148 } else if (role == RejectRole && !cancelButtonAdded) {
149 button.keyEquivalent = @"\e";
150 cancelButtonAdded = true;
151 }
152
153 if (@available(macOS 11, *))
154 button.hasDestructiveAction = role == DestructiveRole;
155
156 // The NSModalResponse of showing an NSAlert normally depends on the order of the
157 // button that was clicked, starting from the right with NSAlertFirstButtonReturn (1000),
158 // NSAlertSecondButtonReturn (1001), NSAlertThirdButtonReturn (1002), and after that
159 // NSAlertThirdButtonReturn + n. The response can also be customized per button via its
160 // tag, which, following the above logic, can include any positive value from 1000 and up.
161 // In addition the system reserves the values from -1000 and down for its own modal responses,
162 // such as NSModalResponseStop, NSModalResponseAbort, and NSModalResponseContinue.
163 // Luckily for us, the QPlatformDialogHelper::StandardButton enum values all fall within
164 // the positive range, so we can use the standard button value as the tag directly.
165 // The same applies to the custom button IDs, as these are generated in sequence after
166 // the QPlatformDialogHelper::LastButton.
167 Q_ASSERT(tag >= NSAlertFirstButtonReturn);
168 button.tag = tag;
169 };
170
171 const auto *platformTheme = QGuiApplicationPrivate::platformTheme();
172 if (auto standardButtons = options()->standardButtons()) {
173 for (int standardButton = FirstButton; standardButton < LastButton; standardButton <<= 1) {
174 if (standardButtons & standardButton) {
175 auto title = platformTheme->standardButtonText(standardButton);
176 addButton(title, standardButton, buttonRole(StandardButton(standardButton)));
177 }
178 }
179 }
180
181 const auto customButtons = options()->customButtons();
182 for (auto customButton : customButtons)
183 addButton(customButton.label, customButton.id, customButton.role);
184
185
186 // QMessageDialog's logic for adding a fallback OK button if no other buttons
187 // are added depends on QMessageBox::showEvent(), which is too late when
188 // native dialogs are in use. To ensure there's always an OK button with a tag
189 // we recognize we add it explicitly here as a fallback.
190 if (!m_alert.buttons.count) {
191 addButton(platformTheme->standardButtonText(StandardButton::Ok),
193 }
194
195 if (auto checkBoxLabel = options()->checkBoxLabel(); !checkBoxLabel.isNull()) {
196 checkBoxLabel = QPlatformTheme::removeMnemonics(checkBoxLabel);
197 m_alert.suppressionButton.title = checkBoxLabel.toNSString();
198 auto state = options()->checkBoxState();
199 m_alert.suppressionButton.allowsMixedState = state == Qt::PartiallyChecked;
200 m_alert.suppressionButton.state = controlStateFor(state);
201 m_alert.showsSuppressionButton = YES;
202 }
203
204 qCDebug(lcQpaDialogs) << "Showing" << m_alert;
205
206 if (windowModality == Qt::WindowModal) {
207 auto *cocoaWindow = static_cast<QCocoaWindow*>(parent->handle());
208 [m_alert beginSheetModalForWindow:cocoaWindow->nativeWindow()
209 completionHandler:^(NSModalResponse response) {
210 processResponse(response);
211 }
212 ];
213 } else {
214 // The dialog is application modal, so we need to call runModal,
215 // but we can't call it here as the nativeDialogInUse state of QDialog
216 // depends on the result of show(), and we can't rely on doing it
217 // in exec(), as we can't guarantee that the user will call exec()
218 // after showing the dialog. As a workaround, we call it from exec(),
219 // but also make sure that if the user returns to the main runloop
220 // we'll run the modal dialog from there.
221 QTimer::singleShot(0, this, [this]{
222 if (m_alert && NSApp.modalWindow != m_alert.window) {
223 qCDebug(lcQpaDialogs) << "Running deferred modal" << m_alert;
224 QCocoaEventDispatcher::clearCurrentThreadCocoaEventDispatcherInterruptFlag();
225 processResponse(runModal());
226 }
227 });
228 }
229
230 return true;
231}
232
233// We shouldn't get NSModalResponseContinue as a response from NSAlert::runModal,
234// and processResponse must not be called with that value (if we are there, it's
235// too late to do anything about it.
236// However, as QTBUG-114546 shows, there are scenarios where we might get that
237// response anyway. We interpret it to keep the modal loop running, and we only
238// return if we got something else to pass to processResponse.
239NSModalResponse QCocoaMessageDialog::runModal() const
240{
241 NSModalResponse response = NSModalResponseContinue;
242 while (response == NSModalResponseContinue)
243 response = [m_alert runModal];
244 return response;
245}
246
248{
249 Q_ASSERT(m_alert);
250
251 if (modality() == Qt::WindowModal) {
252 qCDebug(lcQpaDialogs) << "Running local event loop for window modal" << m_alert;
253 QEventLoop eventLoop;
254 QScopedValueRollback updateGuard(m_eventLoop, &eventLoop);
255 m_eventLoop->exec(QEventLoop::DialogExec);
256 } else {
257 qCDebug(lcQpaDialogs) << "Running modal" << m_alert;
259 processResponse(runModal());
260 }
261}
262
263// Custom modal response code to record that the dialog was hidden by us
264static const NSInteger kModalResponseDialogHidden = NSAlertThirdButtonReturn + 1;
265
266static Qt::CheckState checkStateFor(NSControlStateValue state)
267{
268 switch (state) {
269 case NSControlStateValueOn: return Qt::Checked;
270 case NSControlStateValueOff: return Qt::Unchecked;
271 case NSControlStateValueMixed: return Qt::PartiallyChecked;
272 }
273 Q_UNREACHABLE();
274}
275
276void QCocoaMessageDialog::processResponse(NSModalResponse response)
277{
278 qCDebug(lcQpaDialogs) << "Processing response" << response << "for" << m_alert;
279
280 // We can't re-use the same dialog for the next show() anyways,
281 // since the options may have changed, so get rid of it now,
282 // before we emit anything that might recurse back to hide/show/etc.
283 auto alert = std::exchange(m_alert, nil);
284 [alert autorelease];
285
286 if (alert.showsSuppressionButton)
287 emit checkBoxStateChanged(checkStateFor(alert.suppressionButton.state));
288
289 if (response >= NSAlertFirstButtonReturn) {
290 // Safe range for user-defined modal responses
291 if (response == kModalResponseDialogHidden) {
292 // Dialog was explicitly hidden by us, so nothing to report
293 qCDebug(lcQpaDialogs) << "Dialog was hidden; ignoring response";
294 } else {
295 // Dialog buttons
296 if (response <= StandardButton::LastButton) {
298 auto standardButton = StandardButton(response);
299 emit clicked(standardButton, buttonRole(standardButton));
300 } else {
301 auto *customButton = options()->customButton(response);
302 Q_ASSERT(customButton);
303 emit clicked(StandardButton(customButton->id), customButton->role);
304 }
305 }
306 } else {
307 // We have to consider NSModalResponses beyond the ones specific to
308 // the alert buttons as the alert may be canceled programmatically.
309
310 switch (response) {
311 case NSModalResponseContinue:
312 // Modal session is continuing (returned by runModalSession: only)
313 Q_UNREACHABLE();
314 case NSModalResponseOK:
315 emit accept();
316 break;
317 case NSModalResponseCancel:
318 case NSModalResponseStop: // Modal session was broken with stopModal
319 case NSModalResponseAbort: // Modal session was broken with abortModal
320 emit reject();
321 break;
322 default:
323 qCWarning(lcQpaDialogs) << "Unrecognized modal response" << response;
324 }
325 }
326
327 if (m_eventLoop)
328 m_eventLoop->exit(response);
329}
330
332{
333 if (!m_alert)
334 return;
335
336 if (m_alert.window.visible) {
337 qCDebug(lcQpaDialogs) << "Hiding" << modality() << m_alert;
338
339 // Note: Just hiding or closing the NSAlert's NWindow here is not sufficient,
340 // as the dialog is running a modal event loop as well, which we need to end.
341
342 if (modality() == Qt::WindowModal) {
343 // Will call processResponse() synchronously
344 [m_alert.window.sheetParent endSheet:m_alert.window returnCode:kModalResponseDialogHidden];
345 } else {
346 if (NSApp.modalWindow == m_alert.window) {
347 // Will call processResponse() asynchronously
348 [NSApp stopModalWithCode:kModalResponseDialogHidden];
349 } else {
350 qCWarning(lcQpaDialogs, "Dialog is not top level modal window. Cannot hide.");
351 }
352 }
353 } else {
354 qCDebug(lcQpaDialogs) << "No need to hide already hidden" << m_alert;
355 auto alert = std::exchange(m_alert, nil);
356 [alert autorelease];
357 }
358}
359
360Qt::WindowModality QCocoaMessageDialog::modality() const
361{
362 Q_ASSERT(m_alert && m_alert.window);
363 return m_alert.window.sheetParent ? Qt::WindowModal : Qt::ApplicationModal;
364}
365
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()
Qt::CheckState checkBoxState() const
const CustomButton * customButton(int id)
const QList< CustomButton > & customButtons()
Native interface for QPlatformWindow on \macos. \inmodule QtGui.
QObject * parent() const
Returns a pointer to the parent object.
Definition qobject.h:311
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:412
bool isNull() const
Returns true if this is a null pixmap; otherwise returns false.
Definition qpixmap.cpp:460
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)
bool isNull() const noexcept
Returns true if this object refers to \nullptr.
\macro QT_RESTRICTED_CAST_FROM_ASCII
Definition qstring.h:127
bool isEmpty() const
Returns true if the string has no characters; otherwise returns false.
Definition qstring.h:1083
\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
Q_GUI_EXPORT bool mightBeRichText(const QString &)
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,...)
#define Q_ASSERT(cond)
Definition qrandom.cpp:47
#define emit
#define Q_UNUSED(x)
QString title
[35]
sem release()
IUIAutomationTreeWalker __RPC__deref_out_opt IUIAutomationElement ** parent