1/*
2 SPDX-FileCopyrightText: 2021 Nicolas Fella <nicolas.fella@gmx.de>
3 SPDX-FileCopyrightText: 2021 Alexander Lohnau <alexander.lohnau@gmx.de>
4
5 SPDX-License-Identifier: LGPL-2.0-or-later
6*/
7
8#include "kpluginwidget.h"
9#include "kcmoduleloader.h"
10#include "kpluginproxymodel.h"
11#include "kpluginwidget_p.h"
12
13#include <kcmutils_debug.h>
14
15#include <QApplication>
16#include <QCheckBox>
17#include <QDialog>
18#include <QDialogButtonBox>
19#include <QDir>
20#include <QLineEdit>
21#include <QPainter>
22#include <QPushButton>
23#include <QSortFilterProxyModel>
24#include <QStandardPaths>
25#include <QStyle>
26#include <QStyleOptionViewItem>
27#include <QVBoxLayout>
28
29#include <KAboutPluginDialog>
30#include <KCategorizedSortFilterProxyModel>
31#include <KCategorizedView>
32#include <KCategoryDrawer>
33#include <KLocalizedString>
34#include <KPluginMetaData>
35#include <KStandardGuiItem>
36#include <utility>
37
38static constexpr int s_margin = 5;
39
40int KPluginWidgetPrivate::dependantLayoutValue(int value, int width, int totalWidth) const
41{
42 if (listView->layoutDirection() == Qt::LeftToRight) {
43 return value;
44 }
45
46 return totalWidth - width - value;
47}
48
49KPluginWidget::KPluginWidget(QWidget *parent)
50 : QWidget(parent)
51 , d(new KPluginWidgetPrivate)
52{
53 auto layout = new QVBoxLayout(this);
54 layout->setContentsMargins(left: 0, top: 0, right: 0, bottom: 0);
55 layout->setSpacing(0);
56
57 // Adding content margins on a QLineEdit breaks inline actions
58 auto lineEditWrapper = new QWidget(this);
59 auto lineEditWrapperLayout = new QVBoxLayout(lineEditWrapper);
60 lineEditWrapperLayout->setContentsMargins(left: style()->pixelMetric(metric: QStyle::PM_LayoutLeftMargin),
61 top: style()->pixelMetric(metric: QStyle::PM_LayoutTopMargin),
62 right: style()->pixelMetric(metric: QStyle::PM_LayoutRightMargin),
63 bottom: style()->pixelMetric(metric: QStyle::PM_LayoutBottomMargin));
64
65 d->lineEdit = new QLineEdit(lineEditWrapper);
66 d->lineEdit->setClearButtonEnabled(true);
67 d->lineEdit->setPlaceholderText(i18n("Search..."));
68 lineEditWrapperLayout->addWidget(d->lineEdit);
69 d->listView = new KCategorizedView(this);
70 d->listView->setProperty(name: "_breeze_borders_sides", value: QVariant::fromValue(value: QFlags{Qt::TopEdge}));
71 d->categoryDrawer = new KCategoryDrawer(d->listView);
72 d->listView->setVerticalScrollMode(QListView::ScrollPerPixel);
73 d->listView->setAlternatingRowColors(true);
74 d->listView->setCategoryDrawer(d->categoryDrawer);
75
76 d->pluginModel = new KPluginModel(this);
77
78 connect(sender: d->pluginModel, signal: &KPluginModel::defaulted, context: this, slot: &KPluginWidget::defaulted);
79 connect(sender: d->pluginModel,
80 signal: &QAbstractItemModel::dataChanged,
81 context: this,
82 slot: [this](const QModelIndex &topLeft, const QModelIndex & /*bottomRight*/, const QList<int> &roles) {
83 if (roles.contains(t: KPluginModel::EnabledRole)) {
84 Q_EMIT pluginEnabledChanged(pluginId: topLeft.data(arole: KPluginModel::IdRole).toString(), enabled: topLeft.data(arole: KPluginModel::EnabledRole).toBool());
85 Q_EMIT changed(enabled: d->pluginModel->isSaveNeeded());
86 }
87 });
88
89 d->proxyModel = new KPluginProxyModel(this);
90 d->proxyModel->setModel(d->pluginModel);
91 d->listView->setModel(d->proxyModel);
92 d->listView->setAlternatingRowColors(true);
93
94 auto pluginDelegate = new PluginDelegate(d.get(), this);
95 d->listView->setItemDelegate(pluginDelegate);
96
97 d->listView->setMouseTracking(true);
98 d->listView->viewport()->setAttribute(Qt::WA_Hover);
99
100 connect(sender: d->lineEdit, signal: &QLineEdit::textChanged, context: d->proxyModel, slot: [this](const QString &query) {
101 d->proxyModel->setProperty(name: "query", value: query);
102 d->proxyModel->invalidate();
103 });
104 connect(sender: pluginDelegate, signal: &PluginDelegate::configCommitted, context: this, slot: &KPluginWidget::pluginConfigSaved);
105 connect(sender: pluginDelegate, signal: &PluginDelegate::changed, context: this, slot: &KPluginWidget::pluginEnabledChanged);
106
107 layout->addWidget(lineEditWrapper);
108 layout->addWidget(d->listView);
109
110 // When a KPluginWidget instance gets focus,
111 // it should pass over the focus to its child searchbar.
112 setFocusProxy(d->lineEdit);
113}
114
115KPluginWidget::~KPluginWidget()
116{
117 delete d->listView->itemDelegate();
118 delete d->listView; // depends on some other things in d, make sure this dies first.
119}
120
121void KPluginWidget::addPlugins(const QList<KPluginMetaData> &plugins, const QString &categoryLabel)
122{
123 d->pluginModel->addPlugins(plugins, categoryLabel);
124 d->proxyModel->sort(column: 0);
125}
126
127void KPluginWidget::setConfig(const KConfigGroup &config)
128{
129 d->pluginModel->setConfig(config);
130}
131
132void KPluginWidget::clear()
133{
134 d->pluginModel->clear();
135}
136
137void KPluginWidget::save()
138{
139 d->pluginModel->save();
140}
141
142void KPluginWidget::load()
143{
144 d->pluginModel->load();
145}
146
147void KPluginWidget::defaults()
148{
149 d->pluginModel->defaults();
150}
151
152bool KPluginWidget::isDefault() const
153{
154 for (int i = 0, count = d->pluginModel->rowCount(); i < count; ++i) {
155 const QModelIndex index = d->pluginModel->index(row: i, column: 0);
156 if (d->pluginModel->data(index, role: Qt::CheckStateRole).toBool() != d->pluginModel->data(index, role: KPluginModel::EnabledByDefaultRole).toBool()) {
157 return false;
158 }
159 }
160
161 return true;
162}
163
164bool KPluginWidget::isSaveNeeded() const
165{
166 return d->pluginModel->isSaveNeeded();
167}
168
169void KPluginWidget::setConfigurationArguments(const QVariantList &arguments)
170{
171 d->kcmArguments = arguments;
172}
173
174QVariantList KPluginWidget::configurationArguments() const
175{
176 return d->kcmArguments;
177}
178
179void KPluginWidget::showConfiguration(const QString &pluginId)
180{
181 QModelIndex idx;
182 for (int i = 0, c = d->proxyModel->rowCount(); i < c; ++i) {
183 const auto currentIndex = d->proxyModel->index(row: i, column: 0);
184 const QString id = currentIndex.data(arole: KPluginModel::IdRole).toString();
185 if (id == pluginId) {
186 idx = currentIndex;
187 break;
188 }
189 }
190
191 if (idx.isValid()) {
192 auto delegate = static_cast<PluginDelegate *>(d->listView->itemDelegate());
193 delegate->configure(idx);
194 } else {
195 qCWarning(KCMUTILS_LOG) << "Could not find plugin" << pluginId;
196 }
197}
198
199void KPluginWidget::setDefaultsIndicatorsVisible(bool isVisible)
200{
201 auto delegate = static_cast<PluginDelegate *>(d->listView->itemDelegate());
202 delegate->resetModel();
203
204 d->showDefaultIndicator = isVisible;
205}
206
207void KPluginWidget::setAdditionalButtonHandler(const std::function<QPushButton *(const KPluginMetaData &)> &handler)
208{
209 auto delegate = static_cast<PluginDelegate *>(d->listView->itemDelegate());
210 delegate->handler = handler;
211}
212
213PluginDelegate::PluginDelegate(KPluginWidgetPrivate *pluginSelector_d_ptr, QObject *parent)
214 : KWidgetItemDelegate(pluginSelector_d_ptr->listView, parent)
215 , checkBox(new QCheckBox)
216 , pushButton(new QPushButton)
217 , pluginSelector_d(pluginSelector_d_ptr)
218{
219 // set the icon to make sure the size can be properly calculated
220 pushButton->setIcon(QIcon::fromTheme(QStringLiteral("configure")));
221}
222
223PluginDelegate::~PluginDelegate()
224{
225 delete checkBox;
226 delete pushButton;
227}
228
229void PluginDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
230{
231 if (!index.isValid()) {
232 return;
233 }
234
235 const int xOffset = checkBox->sizeHint().width();
236 const bool disabled = !index.model()->data(index, role: KPluginModel::IsChangeableRole).toBool();
237
238 painter->save();
239
240 QApplication::style()->drawPrimitive(pe: QStyle::PE_PanelItemViewItem, opt: &option, p: painter, w: nullptr);
241
242 const int iconSize = option.rect.height() - (s_margin * 2);
243 QIcon icon = QIcon::fromTheme(name: index.model()->data(index, role: Qt::DecorationRole).toString());
244 icon.paint(painter,
245 rect: QRect(pluginSelector_d->dependantLayoutValue(value: s_margin + option.rect.left() + xOffset, width: iconSize, totalWidth: option.rect.width()),
246 s_margin + option.rect.top(),
247 iconSize,
248 iconSize));
249
250 QRect contentsRect(pluginSelector_d->dependantLayoutValue(value: s_margin * 2 + iconSize + option.rect.left() + xOffset,
251 width: option.rect.width() - (s_margin * 3) - iconSize - xOffset,
252 totalWidth: option.rect.width()),
253 s_margin + option.rect.top(),
254 option.rect.width() - (s_margin * 3) - iconSize - xOffset,
255 option.rect.height() - (s_margin * 2));
256
257 int lessHorizontalSpace = s_margin * 2 + pushButton->sizeHint().width();
258 if (index.model()->data(index, role: KPluginModel::ConfigRole).value<KPluginMetaData>().isValid()) {
259 lessHorizontalSpace += s_margin + pushButton->sizeHint().width();
260 }
261 // Reserve space for extra button
262 if (handler) {
263 lessHorizontalSpace += s_margin + pushButton->sizeHint().width();
264 }
265
266 contentsRect.setWidth(contentsRect.width() - lessHorizontalSpace);
267
268 if (option.state & QStyle::State_Selected) {
269 painter->setPen(option.palette.highlightedText().color());
270 }
271
272 if (pluginSelector_d->listView->layoutDirection() == Qt::RightToLeft) {
273 contentsRect.translate(dx: lessHorizontalSpace, dy: 0);
274 }
275
276 painter->save();
277 if (disabled) {
278 QPalette pal(option.palette);
279 pal.setCurrentColorGroup(QPalette::Disabled);
280 painter->setPen(pal.text().color());
281 }
282
283 painter->save();
284 QFont font = titleFont(baseFont: option.font);
285 QFontMetrics fmTitle(font);
286 painter->setFont(font);
287 painter->drawText(r: contentsRect,
288 flags: Qt::AlignLeft | Qt::AlignTop,
289 text: fmTitle.elidedText(text: index.model()->data(index, role: Qt::DisplayRole).toString(), mode: Qt::ElideRight, width: contentsRect.width()));
290 painter->restore();
291
292 painter->drawText(
293 r: contentsRect,
294 flags: Qt::AlignLeft | Qt::AlignBottom,
295 text: option.fontMetrics.elidedText(text: index.model()->data(index, role: KPluginModel::DescriptionRole).toString(), mode: Qt::ElideRight, width: contentsRect.width()));
296
297 painter->restore();
298 painter->restore();
299}
300
301QSize PluginDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
302{
303 int i = 5;
304 int j = 1;
305 if (index.model()->data(index, role: KPluginModel::ConfigRole).value<KPluginMetaData>().isValid()) {
306 i = 6;
307 j = 2;
308 }
309 // Reserve space for extra button
310 if (handler) {
311 ++j;
312 }
313
314 const QFont font = titleFont(baseFont: option.font);
315 const QFontMetrics fmTitle(font);
316 const QString text = index.model()->data(index, role: Qt::DisplayRole).toString();
317 const QString comment = index.model()->data(index, role: KPluginModel::DescriptionRole).toString();
318 const int maxTextWidth = qMax(a: fmTitle.boundingRect(text).width(), b: option.fontMetrics.boundingRect(text: comment).width());
319
320 const auto iconSize = pluginSelector_d->listView->style()->pixelMetric(metric: QStyle::PM_IconViewIconSize);
321 return QSize(maxTextWidth + iconSize + s_margin * i + pushButton->sizeHint().width() * j,
322 qMax(a: iconSize + s_margin * 2, b: fmTitle.height() + option.fontMetrics.height() + s_margin * 2));
323}
324
325QList<QWidget *> PluginDelegate::createItemWidgets(const QModelIndex &index) const
326{
327 Q_UNUSED(index);
328 QList<QWidget *> widgetList;
329
330 auto enabledCheckBox = new QCheckBox;
331 connect(sender: enabledCheckBox, signal: &QAbstractButton::clicked, context: this, slot: &PluginDelegate::slotStateChanged);
332
333 auto aboutPushButton = new QPushButton;
334 aboutPushButton->setIcon(QIcon::fromTheme(QStringLiteral("dialog-information")));
335 aboutPushButton->setToolTip(i18n("About"));
336 connect(sender: aboutPushButton, signal: &QAbstractButton::clicked, context: this, slot: &PluginDelegate::slotAboutClicked);
337
338 auto configurePushButton = new QPushButton;
339 configurePushButton->setIcon(QIcon::fromTheme(QStringLiteral("configure")));
340 configurePushButton->setToolTip(i18n("Configure"));
341 connect(sender: configurePushButton, signal: &QAbstractButton::clicked, context: this, slot: &PluginDelegate::slotConfigureClicked);
342
343 const static QList<QEvent::Type> blockedEvents{
344 QEvent::MouseButtonPress,
345 QEvent::MouseButtonRelease,
346 QEvent::MouseButtonDblClick,
347 QEvent::KeyPress,
348 QEvent::KeyRelease,
349 };
350 setBlockedEventTypes(widget: enabledCheckBox, types: blockedEvents);
351
352 setBlockedEventTypes(widget: aboutPushButton, types: blockedEvents);
353
354 setBlockedEventTypes(widget: configurePushButton, types: blockedEvents);
355
356 widgetList << enabledCheckBox << aboutPushButton << configurePushButton;
357 if (handler) {
358 QPushButton *btn = handler(pluginSelector_d->pluginModel->data(index, role: KPluginModel::MetaDataRole).value<KPluginMetaData>());
359 if (btn) {
360 widgetList << btn;
361 }
362 }
363
364 return widgetList;
365}
366
367void PluginDelegate::updateItemWidgets(const QList<QWidget *> &widgets, const QStyleOptionViewItem &option, const QPersistentModelIndex &index) const
368{
369 int extraButtonWidth = 0;
370 QPushButton *extraButton = nullptr;
371 if (widgets.count() == 4) {
372 extraButton = static_cast<QPushButton *>(widgets[3]);
373 extraButtonWidth = extraButton->sizeHint().width() + s_margin;
374 }
375 auto checkBox = static_cast<QCheckBox *>(widgets[0]);
376 checkBox->resize(checkBox->sizeHint());
377 checkBox->move(ax: pluginSelector_d->dependantLayoutValue(value: s_margin, width: checkBox->sizeHint().width(), totalWidth: option.rect.width()),
378 ay: option.rect.height() / 2 - checkBox->sizeHint().height() / 2);
379
380 auto aboutPushButton = static_cast<QPushButton *>(widgets[1]);
381 const QSize aboutPushButtonSizeHint = aboutPushButton->sizeHint();
382 aboutPushButton->resize(aboutPushButtonSizeHint);
383 aboutPushButton->move(ax: pluginSelector_d->dependantLayoutValue(value: option.rect.width() - s_margin - aboutPushButtonSizeHint.width() - extraButtonWidth,
384 width: aboutPushButtonSizeHint.width(),
385 totalWidth: option.rect.width()),
386 ay: option.rect.height() / 2 - aboutPushButtonSizeHint.height() / 2);
387
388 auto configurePushButton = static_cast<QPushButton *>(widgets[2]);
389 const QSize configurePushButtonSizeHint = configurePushButton->sizeHint();
390 configurePushButton->resize(configurePushButtonSizeHint);
391 configurePushButton->move(ax: pluginSelector_d->dependantLayoutValue(value: option.rect.width() - s_margin * 2 - configurePushButtonSizeHint.width()
392 - aboutPushButtonSizeHint.width() - extraButtonWidth,
393 width: configurePushButtonSizeHint.width(),
394 totalWidth: option.rect.width()),
395 ay: option.rect.height() / 2 - configurePushButtonSizeHint.height() / 2);
396
397 if (extraButton) {
398 const QSize extraPushButtonSizeHint = extraButton->sizeHint();
399 extraButton->resize(extraPushButtonSizeHint);
400 extraButton->move(ax: pluginSelector_d->dependantLayoutValue(value: option.rect.width() - extraButtonWidth, width: extraPushButtonSizeHint.width(), totalWidth: option.rect.width()),
401 ay: option.rect.height() / 2 - extraPushButtonSizeHint.height() / 2);
402 }
403
404 if (!index.isValid() || !index.internalPointer()) {
405 checkBox->setVisible(false);
406 aboutPushButton->setVisible(false);
407 configurePushButton->setVisible(false);
408 if (extraButton) {
409 extraButton->setVisible(false);
410 }
411 } else {
412 const bool enabledByDefault = index.model()->data(index, role: KPluginModel::EnabledByDefaultRole).toBool();
413 const bool enabled = index.model()->data(index, role: KPluginModel::EnabledRole).toBool();
414 checkBox->setProperty(name: "_kde_highlight_neutral", value: pluginSelector_d->showDefaultIndicator && enabledByDefault != enabled);
415 checkBox->setChecked(index.model()->data(index, role: Qt::CheckStateRole).toBool());
416 checkBox->setEnabled(index.model()->data(index, role: KPluginModel::IsChangeableRole).toBool());
417 configurePushButton->setVisible(index.model()->data(index, role: KPluginModel::ConfigRole).value<KPluginMetaData>().isValid());
418 configurePushButton->setEnabled(index.model()->data(index, role: Qt::CheckStateRole).toBool());
419 }
420}
421
422void PluginDelegate::slotStateChanged(bool state)
423{
424 if (!focusedIndex().isValid()) {
425 return;
426 }
427
428 QModelIndex index = focusedIndex();
429
430 const_cast<QAbstractItemModel *>(index.model())->setData(index, value: state, role: Qt::CheckStateRole);
431}
432
433void PluginDelegate::slotAboutClicked()
434{
435 const QModelIndex index = focusedIndex();
436
437 auto pluginMetaData = index.data(arole: KPluginModel::MetaDataRole).value<KPluginMetaData>();
438
439 auto *aboutPlugin = new KAboutPluginDialog(pluginMetaData, itemView());
440 aboutPlugin->setAttribute(Qt::WA_DeleteOnClose);
441 aboutPlugin->show();
442}
443
444void PluginDelegate::slotConfigureClicked()
445{
446 configure(idx: focusedIndex());
447}
448
449void PluginDelegate::configure(const QModelIndex &index)
450{
451 const QAbstractItemModel *model = index.model();
452 const auto kcm = model->data(index, role: KPluginModel::ConfigRole).value<KPluginMetaData>();
453
454 auto configDialog = new QDialog(itemView());
455 configDialog->setAttribute(Qt::WA_DeleteOnClose);
456 configDialog->setModal(true);
457 configDialog->setWindowTitle(model->data(index, role: KPluginModel::NameRole).toString());
458
459 QWidget *kcmWrapper = new QWidget;
460 auto kcmInstance = KCModuleLoader::loadModule(metaData: kcm, parent: kcmWrapper, args: pluginSelector_d->kcmArguments);
461
462 auto layout = new QVBoxLayout(configDialog);
463 layout->addWidget(kcmWrapper);
464
465 auto buttonBox = new QDialogButtonBox(configDialog);
466 buttonBox->setStandardButtons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel | QDialogButtonBox::RestoreDefaults);
467 KGuiItem::assign(button: buttonBox->button(which: QDialogButtonBox::Ok), item: KStandardGuiItem::ok());
468 KGuiItem::assign(button: buttonBox->button(which: QDialogButtonBox::Cancel), item: KStandardGuiItem::cancel());
469 KGuiItem::assign(button: buttonBox->button(which: QDialogButtonBox::RestoreDefaults), item: KStandardGuiItem::defaults());
470 connect(sender: buttonBox, signal: &QDialogButtonBox::accepted, context: configDialog, slot: &QDialog::accept);
471 connect(sender: buttonBox, signal: &QDialogButtonBox::rejected, context: configDialog, slot: &QDialog::reject);
472 connect(sender: configDialog, signal: &QDialog::accepted, context: this, slot: [kcmInstance, this, model, index]() {
473 Q_EMIT configCommitted(pluginId: model->data(index, role: KPluginModel::IdRole).toString());
474 kcmInstance->save();
475 });
476 connect(sender: configDialog, signal: &QDialog::rejected, context: this, slot: [kcmInstance]() {
477 kcmInstance->load();
478 });
479
480 connect(sender: buttonBox->button(which: QDialogButtonBox::RestoreDefaults), signal: &QAbstractButton::clicked, context: this, slot: [kcmInstance] {
481 kcmInstance->defaults();
482 });
483 layout->addWidget(buttonBox);
484
485 // Load KCM right before showing it
486 kcmInstance->load();
487 configDialog->show();
488}
489
490QFont PluginDelegate::titleFont(const QFont &baseFont) const
491{
492 QFont retFont(baseFont);
493 retFont.setBold(true);
494
495 return retFont;
496}
497
498#include "moc_kpluginwidget.cpp"
499#include "moc_kpluginwidget_p.cpp"
500

source code of kcmutils/src/kpluginwidget.cpp