summaryrefslogtreecommitdiffstats
path: root/src/plugins/platforms/cocoa/qcocoamessagedialog.mm
blob: 8dcc5fab53024f3b0fe0f46b536d94474411652a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only

#include "qcocoamessagedialog.h"

#include "qcocoawindow.h"
#include "qcocoahelpers.h"

#include <QtCore/qmetaobject.h>
#include <QtCore/qscopedvaluerollback.h>
#include <QtCore/qtimer.h>

#include <QtGui/qtextdocument.h>
#include <QtGui/private/qguiapplication_p.h>
#include <QtGui/private/qcoregraphics_p.h>
#include <QtGui/qpa/qplatformtheme.h>

#include <AppKit/NSAlert.h>
#include <AppKit/NSButton.h>

QT_USE_NAMESPACE

using namespace Qt::StringLiterals;

QT_BEGIN_NAMESPACE

QCocoaMessageDialog::~QCocoaMessageDialog()
{
    hide();
    [m_alert release];
}

static QString toPlainText(const QString &text)
{
    // FIXME: QMessageDialog supports Qt::TextFormat, which
    // nowadays includes Qt::MarkdownText, but we don't have
    // the machinery to deal with that yet. We should as a
    // start plumb the dialog's text format to the platform
    // via the dialog options.

    if (!Qt::mightBeRichText(text))
        return text;

    QTextDocument textDocument;
    textDocument.setHtml(text);
    return textDocument.toPlainText();
}

/*
    Called from QDialogPrivate::setNativeDialogVisible() when the message box
    is ready to be shown.

    At this point the options() will reflect the specific dialog shown.

    Returns true if the helper could successfully show the dialog, or
    false if the cross platform fallback dialog should be used instead.
*/
bool QCocoaMessageDialog::show(Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent)
{
    Q_UNUSED(windowFlags);

    qCDebug(lcQpaDialogs) << "Asked to show" << windowModality << "dialog with parent" << parent;

    if (m_alert.window.visible) {
        qCDebug(lcQpaDialogs) << "Dialog already visible, ignoring request to show";
        return true; // But we don't want to  show the fallback dialog instead
    }

    // We can only do application and window modal dialogs
    if (windowModality == Qt::NonModal)
        return false;

    // And only window modal if we have a parent
    if (windowModality == Qt::WindowModal && (!parent || !parent->handle())) {
        qCWarning(lcQpaDialogs, "Cannot run window modal dialog without parent window");
        return false;
    }

    // And without options we don't know what to show
    if (!options())
        return false;

    if (windowModality == Qt::ApplicationModal && QThread::currentThread()->loopLevel() > 1) {
        qCWarning(lcQpaDialogs, "Cannot use native application modal dialog from nested event loop");
        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;
    m_alert.messageText = text.toNSString();
    m_alert.informativeText = toPlainText(options()->informativeText()).toNSString();

    switch (options()->standardIcon()) {
    case QMessageDialogOptions::NoIcon: {
        // We only reflect the pixmap icon if the standard icon is unset,
        // as setting a standard icon will also set a corresponding pixmap
        // icon, which we don't want since it conflicts with the platform.
        // If the user has set an explicit pixmap icon however, the standard
        // icon will be NoIcon, so we're good.
        QPixmap iconPixmap = options()->iconPixmap();
        if (!iconPixmap.isNull())
            m_alert.icon = [NSImage imageFromQImage:iconPixmap.toImage()];
        break;
    }
    case QMessageDialogOptions::Information:
    case QMessageDialogOptions::Question:
        [m_alert setAlertStyle:NSAlertStyleInformational];
        break;
    case QMessageDialogOptions::Warning:
        [m_alert setAlertStyle:NSAlertStyleWarning];
        break;
    case QMessageDialogOptions::Critical:
        [m_alert setAlertStyle:NSAlertStyleCritical];
        break;
    }

    bool defaultButtonAdded = false;
    bool cancelButtonAdded = false;

    const auto addButton = [&](auto title, auto tag, auto role) {
        title = QPlatformTheme::removeMnemonics(title);
        NSButton *button = [m_alert addButtonWithTitle:title.toNSString()];

        // Calling addButtonWithTitle places buttons starting at the right side/top of the alert
        // 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.

        if (role == AcceptRole && !defaultButtonAdded) {
            button.keyEquivalent = @"\r";
            defaultButtonAdded = true;
        } else if (role == RejectRole && !cancelButtonAdded) {
            button.keyEquivalent = @"\e";
            cancelButtonAdded = true;
        }

        if (@available(macOS 11, *))
            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),
        // NSAlertSecondButtonReturn (1001), NSAlertThirdButtonReturn (1002), and after that
        // NSAlertThirdButtonReturn + n. The response can also be customized per button via its
        // tag, which, following the above logic, can include any positive value from 1000 and up.
        // In addition the system reserves the values from -1000 and down for its own modal responses,
        // such as NSModalResponseStop, NSModalResponseAbort, and NSModalResponseContinue.
        // Luckily for us, the QPlatformDialogHelper::StandardButton enum values all fall within
        // the positive range, so we can use the standard button value as the tag directly.
        // The same applies to the custom button IDs, as these are generated in sequence after
        // the QPlatformDialogHelper::LastButton.
        Q_ASSERT(tag >= NSAlertFirstButtonReturn);
        button.tag = tag;
    };

    const auto *platformTheme = QGuiApplicationPrivate::platformTheme();
    if (auto standardButtons = options()->standardButtons()) {
        for (int standardButton = FirstButton; standardButton < LastButton; standardButton <<= 1) {
            if (standardButtons & standardButton) {
                auto title = platformTheme->standardButtonText(standardButton);
                addButton(title, standardButton, buttonRole(StandardButton(standardButton)));
            }
        }
    }

    const auto customButtons = options()->customButtons();
    for (auto customButton : customButtons)
        addButton(customButton.label, customButton.id, customButton.role);


    // 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);
    }

    m_alert.showsSuppressionButton = options()->supressionCheckBoxEnabled();

    qCDebug(lcQpaDialogs) << "Showing" << m_alert;

    if (windowModality == Qt::WindowModal) {
        auto *cocoaWindow = static_cast<QCocoaWindow*>(parent->handle());
        [m_alert beginSheetModalForWindow:cocoaWindow->nativeWindow()
            completionHandler:^(NSModalResponse response) {
                processResponse(response);
            }
        ];
    } else {
        // The dialog is application modal, so we need to call runModal,
        // but we can't call it here as the nativeDialogInUse state of QDialog
        // depends on the result of show(), and we can't rely on doing it
        // in exec(), as we can't guarantee that the user will call exec()
        // after showing the dialog. As a workaround, we call it from exec(),
        // 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) {
                qCDebug(lcQpaDialogs) << "Running deferred modal" << m_alert;
                processResponse([m_alert runModal]);
            }
        });
    }

    return true;
}

void QCocoaMessageDialog::exec()
{
    Q_ASSERT(m_alert);

    if (modality() == Qt::WindowModal) {
        qCDebug(lcQpaDialogs) << "Running local event loop for window modal" << m_alert;
        QEventLoop eventLoop;
        QScopedValueRollback updateGuard(m_eventLoop, &eventLoop);
        m_eventLoop->exec(QEventLoop::DialogExec);
    } else {
        qCDebug(lcQpaDialogs) << "Running modal" << m_alert;
        processResponse([m_alert runModal]);
    }
}

// Custom modal response code to record that the dialog was hidden by us
static const NSInteger kModalResponseDialogHidden = NSAlertThirdButtonReturn + 1;

void QCocoaMessageDialog::processResponse(NSModalResponse response)
{
    qCDebug(lcQpaDialogs) << "Processing response" << response << "for" << m_alert;

    // We can't re-use the same dialog for the next show() anyways,
    // since the options may have changed, so get rid of it now,
    // before we emit anything that might recurse back to hide/show/etc.
    auto alert = std::exchange(m_alert, nil);
    [alert autorelease];

    if (alert.showsSuppressionButton)
        emit supressionCheckBoxChanged(alert.suppressionButton.state == NSControlStateValueOn);

     if (response >= NSAlertFirstButtonReturn) {
        // Safe range for user-defined modal responses
        if (response == kModalResponseDialogHidden) {
            // Dialog was explicitly hidden by us, so nothing to report
            qCDebug(lcQpaDialogs) << "Dialog was hidden; ignoring response";
        } else {
            // Dialog buttons
            if (response <= StandardButton::LastButton) {
                Q_ASSERT(response >= StandardButton::FirstButton);
                auto standardButton = StandardButton(response);
                emit clicked(standardButton, buttonRole(standardButton));
            } else {
                auto *customButton = options()->customButton(response);
                Q_ASSERT(customButton);
                emit clicked(StandardButton(customButton->id), customButton->role);
            }
        }
    } else {
        // We have to consider NSModalResponses beyond the ones specific to
        // the alert buttons as the alert may be canceled programmatically.

        switch (response) {
        case NSModalResponseContinue:
            // Modal session is continuing (returned by runModalSession: only)
            Q_UNREACHABLE();
        case NSModalResponseOK:
            emit accept();
            break;
        case NSModalResponseCancel:
        case NSModalResponseStop: // Modal session was broken with stopModal
        case NSModalResponseAbort: // Modal session was broken with abortModal
            emit reject();
            break;
        default:
            qCWarning(lcQpaDialogs) << "Unrecognized modal response" << response;
        }
    }

    if (m_eventLoop)
        m_eventLoop->exit(response);
}

void QCocoaMessageDialog::hide()
{
    if (!m_alert)
        return;

    if (m_alert.window.visible) {
        qCDebug(lcQpaDialogs) << "Hiding" << modality() << m_alert;

        // Note: Just hiding or closing the NSAlert's NWindow here is not sufficient,
        // as the dialog is running a modal event loop as well, which we need to end.

        if (modality() == Qt::WindowModal) {
            // Will call processResponse() synchronously
            [m_alert.window.sheetParent endSheet:m_alert.window returnCode:kModalResponseDialogHidden];
        } else {
            if (NSApp.modalWindow == m_alert.window) {
                // Will call processResponse() asynchronously
                [NSApp stopModalWithCode:kModalResponseDialogHidden];
            } else {
                qCWarning(lcQpaDialogs, "Dialog is not top level modal window. Cannot hide.");
            }
        }
    } else {
        qCDebug(lcQpaDialogs) << "No need to hide already hidden" << m_alert;
        auto alert = std::exchange(m_alert, nil);
        [alert autorelease];
    }
}

Qt::WindowModality QCocoaMessageDialog::modality() const
{
    Q_ASSERT(m_alert && m_alert.window);
    return m_alert.window.sheetParent ? Qt::WindowModal : Qt::ApplicationModal;
}

QT_END_NAMESPACE