katecommandbar.cpp 15.4 KB
Newer Older
Waqar Ahmed's avatar
Waqar Ahmed committed
1
2
3
4
5
/*
    SPDX-FileCopyrightText: 2021 Waqar Ahmed <waqar.17a@gmail.com>

    SPDX-License-Identifier: LGPL-2.0-or-later
*/
Waqar Ahmed's avatar
Waqar Ahmed committed
6
7
8
9
#include "katecommandbar.h"
#include "commandmodel.h"

#include <QAction>
Alexander Lohnau's avatar
Alexander Lohnau committed
10
#include <QCoreApplication>
Waqar Ahmed's avatar
Waqar Ahmed committed
11
#include <QKeyEvent>
Alexander Lohnau's avatar
Alexander Lohnau committed
12
#include <QLineEdit>
Waqar Ahmed's avatar
Waqar Ahmed committed
13
#include <QPainter>
Alexander Lohnau's avatar
Alexander Lohnau committed
14
#include <QPointer>
15
16
#include <QPushButton>
#include <QSortFilterProxyModel>
Waqar Ahmed's avatar
Waqar Ahmed committed
17
18
#include <QStyledItemDelegate>
#include <QTextDocument>
19
#include <QTextLayout>
Alexander Lohnau's avatar
Alexander Lohnau committed
20
21
#include <QTreeView>
#include <QVBoxLayout>
Waqar Ahmed's avatar
Waqar Ahmed committed
22

23
#include <KActionCollection>
24
#include <KLocalizedString>
25

Waqar Ahmed's avatar
Waqar Ahmed committed
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
#include <kfts_fuzzy_match.h>

class CommandBarFilterModel : public QSortFilterProxyModel
{
public:
    CommandBarFilterModel(QObject *parent = nullptr)
        : QSortFilterProxyModel(parent)
    {
    }

    Q_SLOT void setFilterString(const QString &string)
    {
        beginResetModel();
        m_pattern = string;
        endResetModel();
    }

protected:
    bool lessThan(const QModelIndex &sourceLeft, const QModelIndex &sourceRight) const override
    {
        const int l = sourceLeft.data(CommandModel::Score).toInt();
        const int r = sourceRight.data(CommandModel::Score).toInt();
        return l < r;
    }

    bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override
    {
53
        if (m_pattern.isEmpty()) {
Waqar Ahmed's avatar
Waqar Ahmed committed
54
            return true;
55
        }
Waqar Ahmed's avatar
Waqar Ahmed committed
56
57
58

        int score = 0;
        const auto idx = sourceModel()->index(sourceRow, 0, sourceParent);
59
60
        const QString string = idx.data().toString();
        const QStringView actionName = string.splitRef(QLatin1Char(':')).at(1);
Waqar Ahmed's avatar
Waqar Ahmed committed
61
        const bool res = kfts::fuzzy_match(m_pattern, actionName.trimmed(), score);
Waqar Ahmed's avatar
Waqar Ahmed committed
62
63
64
65
66
67
68
69
        sourceModel()->setData(idx, score, CommandModel::Score);
        return res;
    }

private:
    QString m_pattern;
};

Waqar Ahmed's avatar
Waqar Ahmed committed
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
class CommandBarStyleDelegate : public QStyledItemDelegate
{
public:
    CommandBarStyleDelegate(QObject *parent = nullptr)
        : QStyledItemDelegate(parent)
    {
    }

    void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override
    {
        QStyleOptionViewItem options = option;
        initStyleOption(&options, index);

        painter->save();

        // paint background
        if (option.state & QStyle::State_Selected) {
            painter->fillRect(option.rect, option.palette.highlight());
        } else {
            painter->fillRect(option.rect, option.palette.base());
        }

        options.text = QString(); // clear old text
        options.widget->style()->drawControl(QStyle::CE_ItemViewItem, &options, painter, options.widget);

95
        const auto original = index.data().toString();
Waqar Ahmed's avatar
Waqar Ahmed committed
96
97
        const bool rtl = original.isRightToLeft();
        if (rtl) {
98
99
100
101
102
103
104
105
106
107
108
109
110
111
            painter->translate(-20, 0);
        } else {
            painter->translate(20, 0);
        }

        // must use QString here otherwise fuzzy matching wont
        // work very well
        QString str = original;
        int componentIdx = original.indexOf(QLatin1Char(':'));
        int actionNameStart = 0;
        if (componentIdx > 0) {
            actionNameStart = componentIdx + 2;
            // + 2 because there is a space after colon
            str = str.mid(actionNameStart);
Waqar Ahmed's avatar
Waqar Ahmed committed
112
113
        }

114
        QVector<QTextLayout::FormatRange> formats;
115
        if (componentIdx > 0) {
116
117
            QTextCharFormat gray;
            gray.setForeground(Qt::gray);
118
119
            formats.append({0, componentIdx, gray});
        }
Waqar Ahmed's avatar
Waqar Ahmed committed
120

121
122
123
124
125
126
        QTextCharFormat fmt;
        fmt.setForeground(options.palette.link().color());
        fmt.setFontWeight(QFont::Bold);

        const auto f = kfts::get_fuzzy_match_formats(m_filterString, str, componentIdx + 2, fmt);
        formats.append(f);
Waqar Ahmed's avatar
Waqar Ahmed committed
127

128
        kfts::paintItemViewText(painter, original, options, std::move(formats));
Waqar Ahmed's avatar
Waqar Ahmed committed
129
130
131
132
133
134
135
136
137
138
139
140
141
142

        painter->restore();
    }

public Q_SLOTS:
    void setFilterString(const QString &text)
    {
        m_filterString = text;
    }

private:
    QString m_filterString;
};

143
144
class ShortcutStyleDelegate : public QStyledItemDelegate
{
145
146
147
148
149
#if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
    static constexpr auto SkipEmptyParts = QString::SkipEmptyParts;
#else
    static constexpr auto SkipEmptyParts = Qt::SkipEmptyParts;
#endif
150
151
152
153
154
155
public:
    ShortcutStyleDelegate(QObject *parent = nullptr)
        : QStyledItemDelegate(parent)
    {
    }

156
157
158
159
160
    static QStringList splitShortcutString(const QString &shortcutString)
    {
        return shortcutString.split(QLatin1String(", "), SkipEmptyParts);
    }

161
162
163
164
165
166
    void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override
    {
        QStyleOptionViewItem options = option;
        initStyleOption(&options, index);
        painter->save();

167
168
        const auto shortcutString = index.data().toString();

169
170
171
172
173
174
175
176
177
178
        // paint background
        if (option.state & QStyle::State_Selected) {
            painter->fillRect(option.rect, option.palette.highlight());
        } else {
            painter->fillRect(option.rect, option.palette.base());
        }

        options.text = QString(); // clear old text
        options.widget->style()->drawControl(QStyle::CE_ItemViewItem, &options, painter, options.widget);

179
        if (!shortcutString.isEmpty()) {
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
            /**
             * Shortcut string splitting
             *
             * We do it in two steps
             * 1. Split on ", " so that if we have multi modifier shortcuts they are nicely
             *    splitted into strings.
             * 2. Split each shortcut from step 1 into individual string.
             *
             * Example:
             *
             * "Ctrl+,, Alt+:"
             * Step 1: [ "Ctrl+," , "Alt+:"]
             * Step 2: [ "Ctrl", ",", "Alt", ":"]
             */
            const auto spaceSplitted = splitShortcutString(shortcutString);
Waqar Ahmed's avatar
Waqar Ahmed committed
195
196
197
            QStringList list;
            list.reserve(spaceSplitted.size() * 2);
            for (const QString &shortcut : spaceSplitted) {
198
                list += shortcut.split(QLatin1Char('+'), SkipEmptyParts);
Waqar Ahmed's avatar
Waqar Ahmed committed
199
200
                if (shortcut.endsWith(QLatin1String("+"))) {
                    list.append(QStringLiteral("+"));
201
                }
Waqar Ahmed's avatar
Waqar Ahmed committed
202
            }
203
            Q_ASSERT(!list.isEmpty());
204
205
206
207
208
209
210
211
212

            /**
             * Create rects for each string from the previous step
             *
             * @todo boundingRect may give issues here, use horizontalAdvance
             * @todo We probably dont need the full rect, just the width so the
             * "btns" vector can just be vector<pair<int, string>>
             */
            QVector<QPair<QRect, QString>> btns;
213
            btns.reserve(list.size());
214
            const int height = options.rect.height();
Alexander Lohnau's avatar
Alexander Lohnau committed
215
            for (const QString &text : list) {
216
217
218
                if (text.isEmpty()) {
                    continue;
                }
219
                QRect r = option.fontMetrics.boundingRect(text);
220
221
222
223
224
                // this happens on gnome so we manually decrease the
                // height a bit
                if (r.height() == height) {
                    r.setHeight(r.height() - 4);
                }
225
                r.setWidth(r.width() + 8);
226
                btns.append({r, text});
227
            }
228
            Q_ASSERT(!btns.isEmpty());
229

230
231
232
233
234
            // we have nothing, just return
            if (btns.isEmpty()) {
                return;
            }

235
            const auto plusRect = option.fontMetrics.boundingRect(QLatin1Char('+'));
236
237

            // draw them
238
239
240
241
            int x = option.rect.x();
            const int y = option.rect.y();
            const int plusY = option.rect.y() + plusRect.height() / 2;
            const int total = btns.size();
242
243
244
245

            // make sure our rects are nicely V-center aligned in the row
            painter->translate(QPoint(0, (option.rect.height() - btns.at(0).first.height()) / 2));

246
            int i = 0;
247
            painter->setRenderHint(QPainter::Antialiasing);
Alexander Lohnau's avatar
Alexander Lohnau committed
248
            for (const auto &btn : btns) {
249
                painter->setPen(Qt::NoPen);
Alexander Lohnau's avatar
Alexander Lohnau committed
250
                const QRect &rect = btn.first;
251
252

                QRect buttonRect(x, y, rect.width(), rect.height());
253

254
255
                // draw rounded rect shadow
                auto shadowRect = buttonRect.translated(0, 1);
256
                painter->setBrush(option.palette.shadow());
257
                painter->drawRoundedRect(shadowRect, 3, 3);
258
259
260

                // draw rounded rect itself
                painter->setBrush(option.palette.button());
261
                painter->drawRoundedRect(buttonRect, 3, 3);
262
263
264

                // draw text inside rounded rect
                painter->setPen(option.palette.buttonText().color());
265
                painter->drawText(buttonRect, Qt::AlignCenter, btn.second);
266
267

                // draw '+'
268
                if (i + 1 < total) {
269
                    x += rect.width() + 5;
270
                    painter->drawText(QPoint(x, plusY + (rect.height() / 2)), QStringLiteral("+"));
271
                    x += plusRect.width() + 5;
272
273
274
275
276
277
278
279
280
                }
                i++;
            }
        }

        painter->restore();
    }
};

Waqar Ahmed's avatar
Waqar Ahmed committed
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
KateCommandBar::KateCommandBar(QWidget *parent)
    : QMenu(parent)
{
    QVBoxLayout *layout = new QVBoxLayout();
    layout->setSpacing(0);
    layout->setContentsMargins(4, 4, 4, 4);
    setLayout(layout);

    m_lineEdit = new QLineEdit(this);
    setFocusProxy(m_lineEdit);

    layout->addWidget(m_lineEdit);

    m_treeView = new QTreeView();
    layout->addWidget(m_treeView, 1);
    m_treeView->setTextElideMode(Qt::ElideLeft);
    m_treeView->setUniformRowHeights(true);

    m_model = new CommandModel(this);

Alexander Lohnau's avatar
Alexander Lohnau committed
301
302
    CommandBarStyleDelegate *delegate = new CommandBarStyleDelegate(this);
    ShortcutStyleDelegate *del = new ShortcutStyleDelegate(this);
Waqar Ahmed's avatar
Waqar Ahmed committed
303
    m_treeView->setItemDelegateForColumn(0, delegate);
304
    m_treeView->setItemDelegateForColumn(1, del);
Waqar Ahmed's avatar
Waqar Ahmed committed
305

Waqar Ahmed's avatar
Waqar Ahmed committed
306
307
308
309
310
311
312
    m_proxyModel = new CommandBarFilterModel(this);
    m_proxyModel->setFilterRole(Qt::DisplayRole);
    m_proxyModel->setSortRole(CommandModel::Score);
    m_proxyModel->setFilterKeyColumn(0);

    connect(m_lineEdit, &QLineEdit::returnPressed, this, &KateCommandBar::slotReturnPressed);
    connect(m_lineEdit, &QLineEdit::textChanged, m_proxyModel, &CommandBarFilterModel::setFilterString);
Waqar Ahmed's avatar
Waqar Ahmed committed
313
    connect(m_lineEdit, &QLineEdit::textChanged, delegate, &CommandBarStyleDelegate::setFilterString);
Alexander Lohnau's avatar
Alexander Lohnau committed
314
    connect(m_lineEdit, &QLineEdit::textChanged, this, [this]() {
Waqar Ahmed's avatar
Waqar Ahmed committed
315
316
317
318
        m_treeView->viewport()->update();
        reselectFirst();
    });
    connect(m_treeView, &QTreeView::clicked, this, &KateCommandBar::slotReturnPressed);
Waqar Ahmed's avatar
Waqar Ahmed committed
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334

    m_proxyModel->setSourceModel(m_model);
    m_treeView->setSortingEnabled(true);
    m_treeView->setModel(m_proxyModel);

    m_treeView->installEventFilter(this);
    m_lineEdit->installEventFilter(this);

    m_treeView->setHeaderHidden(true);
    m_treeView->setRootIsDecorated(false);
    m_treeView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
    m_treeView->setSelectionMode(QTreeView::SingleSelection);

    setHidden(true);
}

335
void KateCommandBar::updateBar(const QList<KActionCollection *> &actionCollections, int totalActions)
Waqar Ahmed's avatar
Waqar Ahmed committed
336
{
Alexander Lohnau's avatar
Alexander Lohnau committed
337
    QVector<QPair<QString, QAction *>> actionList;
338
339
    actionList.reserve(totalActions);

340
    for (const auto collection : actionCollections) {
Alexander Lohnau's avatar
Alexander Lohnau committed
341
        const QList<QAction *> collectionActions = collection->actions();
342
        const QString componentName = collection->componentDisplayName();
343
        for (const auto action : collectionActions) {
344
345
346
            // sanity + empty check ensures displayable actions and removes ourself
            // from the action list
            if (action && !action->text().isEmpty()) {
347
                actionList.append({componentName, action});
348
            }
349
350
351
        }
    }

352
    m_model->refresh(std::move(actionList));
Waqar Ahmed's avatar
Waqar Ahmed committed
353
354
355
356
357
358
359
360
361
362
363
364
365
    reselectFirst();

    updateViewGeometry();
    show();
    setFocus();
}

bool KateCommandBar::eventFilter(QObject *obj, QEvent *event)
{
    // catch key presses + shortcut overrides to allow to have ESC as application wide shortcut, too, see bug 409856
    if (event->type() == QEvent::KeyPress || event->type() == QEvent::ShortcutOverride) {
        QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
        if (obj == m_lineEdit) {
Alexander Lohnau's avatar
Alexander Lohnau committed
366
367
            const bool forward2list = (keyEvent->key() == Qt::Key_Up) || (keyEvent->key() == Qt::Key_Down) || (keyEvent->key() == Qt::Key_PageUp)
                || (keyEvent->key() == Qt::Key_PageDown);
Waqar Ahmed's avatar
Waqar Ahmed committed
368
369
370
371
372
373
374
375
376
377
378
379
            if (forward2list) {
                QCoreApplication::sendEvent(m_treeView, event);
                return true;
            }

            if (keyEvent->key() == Qt::Key_Escape) {
                m_lineEdit->clear();
                keyEvent->accept();
                hide();
                return true;
            }
        } else {
Alexander Lohnau's avatar
Alexander Lohnau committed
380
381
            const bool forward2input = (keyEvent->key() != Qt::Key_Up) && (keyEvent->key() != Qt::Key_Down) && (keyEvent->key() != Qt::Key_PageUp)
                && (keyEvent->key() != Qt::Key_PageDown) && (keyEvent->key() != Qt::Key_Tab) && (keyEvent->key() != Qt::Key_Backtab);
Waqar Ahmed's avatar
Waqar Ahmed committed
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
            if (forward2input) {
                QCoreApplication::sendEvent(m_lineEdit, event);
                return true;
            }
        }
    }

    // hide on focus out, if neither input field nor list have focus!
    else if (event->type() == QEvent::FocusOut && !(m_lineEdit->hasFocus() || m_treeView->hasFocus())) {
        m_lineEdit->clear();
        hide();
        return true;
    }

    return QWidget::eventFilter(obj, event);
}

void KateCommandBar::slotReturnPressed()
{
Alexander Lohnau's avatar
Alexander Lohnau committed
401
    auto act = m_proxyModel->data(m_treeView->currentIndex(), Qt::UserRole).value<QAction *>();
402
403
404
405
406
    if (act) {
        // if the action is a menu, we take all its actions
        // and reload our dialog with these instead.
        if (auto menu = act->menu()) {
            auto menuActions = menu->actions();
Alexander Lohnau's avatar
Alexander Lohnau committed
407
            QVector<QPair<QString, QAction *>> list;
408
            list.reserve(menuActions.size());
409
410
411
412
413
414
415
416

            // if there are no actions, trigger load actions
            // this happens with some menus that are loaded on demand
            if (menuActions.size() == 0) {
                Q_EMIT menu->aboutToShow();
                menuActions = menu->actions();
            }

417
            for (auto menuAction : qAsConst(menuActions)) {
418
419
420
421
422
423
424
425
                if (menuAction) {
                    list.append({KLocalizedString::removeAcceleratorMarker(act->text()), menuAction});
                }
            }
            m_model->refresh(list);
            m_lineEdit->clear();
            return;
        } else {
426
            m_model->actionTriggered(act->text());
427
428
429
            act->trigger();
        }
    }
Waqar Ahmed's avatar
Waqar Ahmed committed
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
    m_lineEdit->clear();
    hide();
}

void KateCommandBar::reselectFirst()
{
    QModelIndex index = m_proxyModel->index(0, 0);
    m_treeView->setCurrentIndex(index);
}

void KateCommandBar::updateViewGeometry()
{
    m_treeView->resizeColumnToContents(0);
    m_treeView->resizeColumnToContents(1);

    const QSize centralSize = parentWidget()->size();

    // width: 2.4 of editor, height: 1/2 of editor
    const QSize viewMaxSize(centralSize.width() / 2.4, centralSize.height() / 2);

    // Position should be central over window
Waqar Ahmed's avatar
Waqar Ahmed committed
451
452
    const int xPos = std::max(0, (centralSize.width() - viewMaxSize.width()) / 2);
    const int yPos = std::max(0, (centralSize.height() - viewMaxSize.height()) * 1 / 4);
Waqar Ahmed's avatar
Waqar Ahmed committed
453
454
455
456

    const QPoint p(xPos, yPos);
    move(p + parentWidget()->pos());

Waqar Ahmed's avatar
Waqar Ahmed committed
457
    this->setFixedSize(viewMaxSize);
Waqar Ahmed's avatar
Waqar Ahmed committed
458
}
459
460
461
462
463
464
465
466
467
468

void KateCommandBar::setLastUsedCmdBarActions(const QVector<QString> &actionNames)
{
    return m_model->setLastUsedActions(actionNames);
}

QVector<QString> KateCommandBar::lastUsedCmdBarActions() const
{
    return m_model->lastUsedActions();
}