summaryrefslogtreecommitdiffstats
path: root/src/plugins/platforms/cocoa/qcocoamessagedialog.mm
blob: 84525099c96a9fd3f4f42cf1dbeee742a6ac1bff (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
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
// 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 "qcocoaeventdispatcher.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();
}

static NSControlStateValue controlStateFor(Qt::CheckState state)
{
    switch (state) {
    case Qt::Checked: return NSControlStateValueOn;
    case Qt::Unchecked: return NSControlStateValueOff;
    case Qt::PartiallyChecked: return NSControlStateValueMixed;
    }
    Q_UNREACHABLE();
}

/*
    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;

    // NSAlert doesn't have a section for detailed text
    if (!options()->detailedText().isEmpty()) {
        qCWarning(lcQpaDialogs, "Message box contains detailed text");
        return false;
    }

    if (Qt::mightBeRichText(options()->text()) ||
        Qt::mightBeRichText(options()->informativeText())) {
        // Let's fallback to non-native message box,
        // we only have plain NSString/text in NSAlert.
        qCDebug(lcQpaDialogs, "Message box contains text in rich text format");
        return false;
    }

    Q_ASSERT(!m_alert);
    m_alert = [NSAlert new];
    m_alert.window.title = options()->windowTitle().toNSString();

    const QString text = toPlainText(options()->text());
    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;
    }

    auto defaultButton = options()->defaultButton();
    auto escapeButton = options()->escapeButton();

    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). If an explicit default or escape button has been set, we respect these,
        // and otherwise we fall back to role-based default and escape buttons.

        qCDebug(lcQpaDialogs).verbosity(0) << "Adding button" << title << "with" << role;

        if (!defaultButton && role == AcceptRole)
            defaultButton = tag;

        if (tag == defaultButton)
            button.keyEquivalent = @"\r";
        else if ([button.keyEquivalent isEqualToString:@"\r"])
            button.keyEquivalent = @"";

        if (!escapeButton && role == RejectRole)
            escapeButton = tag;

        // Don't override default button with escape button, to match AppKit default
        if (tag == escapeButton && ![button.keyEquivalent isEqualToString:@"\r"])
            button.keyEquivalent = @"\e";
        else if ([button.keyEquivalent isEqualToString:@"\e"])
            button.keyEquivalent = @"";

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

    // Resolve all dialog buttons from the options, both standard and custom

    struct Button { QString title; int identifier; ButtonRole role; };
    std::vector<Button> buttons;

    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);
                buttons.push_back({
                    title, standardButton, buttonRole(StandardButton(standardButton))
                });
            }
        }
    }
    const auto customButtons = options()->customButtons();
    for (auto customButton : customButtons)
        buttons.push_back({customButton.label, customButton.id, customButton.role});

    // Sort them according to the QPlatformDialogHelper::ButtonLayout for macOS

    // The ButtonLayout adds one additional role, AlternateRole, which is used
    // for any AcceptRole beyond the first one, and should be ordered before the
    // AcceptRole. Set this up by fixing the roles up front.
    bool seenAccept = false;
    for (auto &button : buttons) {
        if (button.role == AcceptRole) {
            if (!seenAccept)
                seenAccept = true;
            else
                button.role = AlternateRole;
        }
    }

    std::vector<Button> orderedButtons;
    const int *layoutEntry = buttonLayout(Qt::Horizontal, ButtonLayout::MacLayout);
    while (*layoutEntry != QPlatformDialogHelper::EOL) {
        const auto role = ButtonRole(*layoutEntry & ~ButtonRole::Reverse);
        const bool reverse = *layoutEntry & ButtonRole::Reverse;

        auto addButton = [&](const Button &button) {
            if (button.role == role)
                orderedButtons.push_back(button);
        };

        if (reverse)
            std::for_each(std::crbegin(buttons), std::crend(buttons), addButton);
        else
            std::for_each(std::cbegin(buttons), std::cend(buttons), addButton);

        ++layoutEntry;
    }

    // Add them to the alert in reverse order, since buttons are added right to left
    for (auto button = orderedButtons.crbegin(); button != orderedButtons.crend(); ++button)
        addButton(button->title, button->identifier, button->role);

    // If we didn't find a an explicit or implicit default button above
    // we restore the AppKit behavior of making the first button default.
    if (!defaultButton)
        m_alert.buttons.firstObject.keyEquivalent = @"\r";

    if (auto checkBoxLabel = options()->checkBoxLabel(); !checkBoxLabel.isNull()) {
        checkBoxLabel = QPlatformTheme::removeMnemonics(checkBoxLabel);
        m_alert.suppressionButton.title = checkBoxLabel.toNSString();
        auto state = options()->checkBoxState();
        m_alert.suppressionButton.allowsMixedState = state == Qt::PartiallyChecked;
        m_alert.suppressionButton.state = controlStateFor(state);
        m_alert.showsSuppressionButton = YES;
    }

    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 && !m_alert.window.visible) {
                qCDebug(lcQpaDialogs) << "Running deferred modal" << m_alert;
                QCocoaEventDispatcher::clearCurrentThreadCocoaEventDispatcherInterruptFlag();
                processResponse(runModal());
            }
        });
    }

    return true;
}

// We shouldn't get NSModalResponseContinue as a response from NSAlert::runModal,
// and processResponse must not be called with that value (if we are there, it's
// too late to do anything about it.
// However, as QTBUG-114546 shows, there are scenarios where we might get that
// response anyway. We interpret it to keep the modal loop running, and we only
// return if we got something else to pass to processResponse.
NSModalResponse QCocoaMessageDialog::runModal() const
{
    NSModalResponse response = NSModalResponseContinue;
    while (response == NSModalResponseContinue)
        response = [m_alert runModal];
    return response;
}

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;
        QCocoaEventDispatcher::clearCurrentThreadCocoaEventDispatcherInterruptFlag();
        processResponse(runModal());
    }
}

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

static Qt::CheckState checkStateFor(NSControlStateValue state)
{
    switch (state) {
    case NSControlStateValueOn: return Qt::Checked;
    case NSControlStateValueOff: return Qt::Unchecked;
    case NSControlStateValueMixed: return Qt::PartiallyChecked;
    }
    Q_UNREACHABLE();
}

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 checkBoxStateChanged(checkStateFor(alert.suppressionButton.state));

     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