1/*
2 SPDX-FileCopyrightText: 2019 Dominik Haumann <dhaumann@kde.org>
3
4 SPDX-License-Identifier: LGPL-2.0-or-later
5*/
6
7#include "katevariableexpansionhelpers.h"
8
9#include "variable.h"
10
11#include <KLocalizedString>
12
13#include <KTextEditor/Application>
14#include <KTextEditor/Editor>
15#include <KTextEditor/MainWindow>
16
17#include <QAbstractItemModel>
18#include <QAction>
19#include <QCoreApplication>
20#include <QEvent>
21#include <QHelpEvent>
22#include <QLabel>
23#include <QLineEdit>
24#include <QListView>
25#include <QSortFilterProxyModel>
26#include <QStyleOptionToolButton>
27#include <QStylePainter>
28#include <QTextEdit>
29#include <QToolButton>
30#include <QToolTip>
31#include <QVBoxLayout>
32
33/**
34 * Find closing bracket for @p str starting a position @p pos.
35 */
36static int findClosing(QStringView str, int pos = 0)
37{
38 const int len = str.size();
39 int nesting = 0;
40
41 while (pos < len) {
42 const QChar c = str[pos];
43 if (c == QLatin1Char('}')) {
44 if (nesting == 0) {
45 return pos;
46 }
47 nesting--;
48 } else if (c == QLatin1Char('{')) {
49 nesting++;
50 }
51 ++pos;
52 }
53 return -1;
54}
55
56namespace KateMacroExpander
57{
58QString expandMacro(const QString &input, KTextEditor::View *view)
59{
60 QString output = input;
61 QString oldStr;
62 do {
63 oldStr = output;
64 const int startIndex = output.indexOf(s: QLatin1String("%{"));
65 if (startIndex < 0) {
66 break;
67 }
68
69 const int endIndex = findClosing(str: output, pos: startIndex + 2);
70 if (endIndex <= startIndex) {
71 break;
72 }
73
74 const int varLen = endIndex - (startIndex + 2);
75 QString variable = output.mid(position: startIndex + 2, n: varLen);
76 variable = expandMacro(input: variable, view);
77 if (KTextEditor::Editor::instance()->expandVariable(variable, view, output&: variable)) {
78 output.replace(i: startIndex, len: endIndex - startIndex + 1, after: variable);
79 }
80 } while (output != oldStr); // str comparison guards against infinite loop
81 return output;
82}
83
84}
85
86class VariableItemModel : public QAbstractItemModel
87{
88public:
89 VariableItemModel(QObject *parent = nullptr)
90 : QAbstractItemModel(parent)
91 {
92 }
93
94 QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override
95 {
96 if (parent.isValid() || row < 0 || row >= m_variables.size()) {
97 return {};
98 }
99
100 return createIndex(arow: row, acolumn: column);
101 }
102
103 QModelIndex parent(const QModelIndex &index) const override
104 {
105 Q_UNUSED(index)
106 // flat list -> we never have parents
107 return {};
108 }
109
110 int rowCount(const QModelIndex &parent = QModelIndex()) const override
111 {
112 return parent.isValid() ? 0 : m_variables.size();
113 }
114
115 int columnCount(const QModelIndex &parent = QModelIndex()) const override
116 {
117 Q_UNUSED(parent)
118 return 3; // name | description | current value
119 }
120
121 QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override
122 {
123 if (!index.isValid()) {
124 return {};
125 }
126
127 const auto &var = m_variables[index.row()];
128 switch (role) {
129 case Qt::DisplayRole: {
130 const QString suffix = var.isPrefixMatch() ? i18n("<value>") : QString();
131 return QString(var.name() + suffix);
132 }
133 case Qt::ToolTipRole:
134 return var.description();
135 }
136
137 return {};
138 }
139
140 void setVariables(const QList<KTextEditor::Variable> &variables)
141 {
142 beginResetModel();
143 m_variables = variables;
144 endResetModel();
145 }
146
147private:
148 QList<KTextEditor::Variable> m_variables;
149};
150
151class TextEditButton : public QToolButton
152{
153public:
154 TextEditButton(QAction *showAction, QTextEdit *parent)
155 : QToolButton(parent)
156 {
157 setAutoRaise(true);
158 setDefaultAction(showAction);
159 m_watched = parent->viewport();
160 m_watched->installEventFilter(filterObj: this);
161 show();
162 adjustPosition(parentSize: m_watched->size());
163 }
164
165protected:
166 void paintEvent(QPaintEvent *) override
167 {
168 // reimplement to have same behavior as actions in QLineEdits
169 QStylePainter p(this);
170 QStyleOptionToolButton opt;
171 initStyleOption(option: &opt);
172 opt.state = opt.state & ~QStyle::State_Raised;
173 opt.state = opt.state & ~QStyle::State_MouseOver;
174 opt.state = opt.state & ~QStyle::State_Sunken;
175 p.drawComplexControl(cc: QStyle::CC_ToolButton, opt);
176 }
177
178public:
179 bool eventFilter(QObject *watched, QEvent *event) override
180 {
181 if (watched == m_watched) {
182 switch (event->type()) {
183 case QEvent::Resize: {
184 auto resizeEvent = static_cast<QResizeEvent *>(event);
185 adjustPosition(parentSize: resizeEvent->size());
186 }
187 default:
188 break;
189 }
190 }
191 return QToolButton::eventFilter(watched, event);
192 }
193
194private:
195 void adjustPosition(const QSize &parentSize)
196 {
197 QStyleOption sopt;
198 sopt.initFrom(w: parentWidget());
199 const int topMargin = 0; // style()->pixelMetric(QStyle::PM_LayoutTopMargin, &sopt, parentWidget());
200 const int rightMargin = 0; // style()->pixelMetric(QStyle::PM_LayoutRightMargin, &sopt, parentWidget());
201 if (isLeftToRight()) {
202 move(ax: parentSize.width() - width() - rightMargin, ay: topMargin);
203 } else {
204 move(ax: 0, ay: 0);
205 }
206 }
207
208private:
209 QWidget *m_watched;
210};
211
212KateVariableExpansionDialog::KateVariableExpansionDialog(QWidget *parent)
213 : QDialog(parent, Qt::Tool)
214 , m_showAction(new QAction(QIcon::fromTheme(QStringLiteral("code-context")), i18n("Insert variable"), this))
215 , m_variableModel(new VariableItemModel(this))
216 , m_listView(new QListView(this))
217{
218 setWindowTitle(i18n("Variables"));
219
220 auto vbox = new QVBoxLayout(this);
221 m_filterEdit = new QLineEdit(this);
222 m_filterEdit->setPlaceholderText(i18n("Filter"));
223 m_filterEdit->setFocus();
224 m_filterEdit->installEventFilter(filterObj: this);
225 vbox->addWidget(m_filterEdit);
226 vbox->addWidget(m_listView);
227 m_listView->setUniformItemSizes(true);
228
229 m_filterModel = new QSortFilterProxyModel(this);
230 m_filterModel->setFilterRole(Qt::DisplayRole);
231 m_filterModel->setSortRole(Qt::DisplayRole);
232 m_filterModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
233 m_filterModel->setSortCaseSensitivity(Qt::CaseInsensitive);
234 m_filterModel->setFilterKeyColumn(0);
235
236 m_filterModel->setSourceModel(m_variableModel);
237 m_listView->setModel(m_filterModel);
238
239 connect(sender: m_filterEdit, signal: &QLineEdit::textChanged, context: m_filterModel, slot: &QSortFilterProxyModel::setFilterWildcard);
240
241 auto lblDescription = new QLabel(i18n("Please select a variable."), this);
242 lblDescription->setWordWrap(true);
243 lblDescription->setTextFormat(Qt::PlainText);
244 auto lblCurrentValue = new QLabel(this);
245 lblCurrentValue->setWordWrap(true);
246 lblCurrentValue->setTextFormat(Qt::PlainText);
247
248 vbox->addWidget(lblDescription);
249 vbox->addWidget(lblCurrentValue);
250
251 // react to selection changes
252 connect(sender: m_listView->selectionModel(),
253 signal: &QItemSelectionModel::currentRowChanged,
254 slot: [this, lblDescription, lblCurrentValue](const QModelIndex &current, const QModelIndex &) {
255 if (current.isValid()) {
256 const auto &var = m_variables[m_filterModel->mapToSource(proxyIndex: current).row()];
257 lblDescription->setText(var.description());
258 if (var.isPrefixMatch()) {
259 lblCurrentValue->setText(i18n("Current value: %1<value>", var.name()));
260 } else {
261 auto activeView = KTextEditor::Editor::instance()->application()->activeMainWindow()->activeView();
262 auto value = var.evaluate(prefix: var.name(), view: activeView);
263
264 // ensure content like from document doesn't make the dialog size explode, bug 497328
265 value = QFontMetrics(lblCurrentValue->font()).elidedText(text: value, mode: Qt::ElideRight, width: width());
266
267 lblCurrentValue->setText(i18n("Current value: %1", value));
268 }
269 } else {
270 lblDescription->setText(i18n("Please select a variable."));
271 lblCurrentValue->clear();
272 }
273 });
274
275 // insert text on activation
276 connect(sender: m_listView, signal: &QAbstractItemView::activated, slot: [this](const QModelIndex &index) {
277 if (index.isValid()) {
278 const auto &var = m_variables[m_filterModel->mapToSource(proxyIndex: index).row()];
279
280 // not auto, don't fall for string builder, see bug 413474
281 const QString name = QStringLiteral("%{") + var.name() + QLatin1Char('}');
282 if (parentWidget() && parentWidget()->window()) {
283 auto currentWidget = parentWidget()->window()->focusWidget();
284 if (auto lineEdit = qobject_cast<QLineEdit *>(object: currentWidget)) {
285 lineEdit->insert(name);
286 } else if (auto textEdit = qobject_cast<QTextEdit *>(object: currentWidget)) {
287 textEdit->insertPlainText(text: name);
288 }
289 }
290 }
291 });
292
293 // show dialog whenever the action is clicked
294 connect(sender: m_showAction, signal: &QAction::triggered, slot: [this]() {
295 show();
296 activateWindow();
297 });
298
299 resize(w: 400, h: 550);
300}
301
302KateVariableExpansionDialog::~KateVariableExpansionDialog()
303{
304 for (auto it = m_textEditButtons.begin(); it != m_textEditButtons.end(); ++it) {
305 if (it.value()) {
306 delete it.value();
307 }
308 }
309 m_textEditButtons.clear();
310}
311
312void KateVariableExpansionDialog::addVariable(const KTextEditor::Variable &variable)
313{
314 Q_ASSERT(variable.isValid());
315 m_variables.push_back(t: variable);
316
317 m_variableModel->setVariables(m_variables);
318}
319
320int KateVariableExpansionDialog::isEmpty() const
321{
322 return m_variables.isEmpty();
323}
324
325void KateVariableExpansionDialog::addWidget(QWidget *widget)
326{
327 m_widgets.push_back(t: widget);
328 widget->installEventFilter(filterObj: this);
329
330 connect(sender: widget, signal: &QObject::destroyed, context: this, slot: &KateVariableExpansionDialog::onObjectDeleted);
331}
332
333void KateVariableExpansionDialog::onObjectDeleted(QObject *object)
334{
335 m_widgets.removeAll(t: object);
336 if (m_widgets.isEmpty()) {
337 deleteLater();
338 }
339}
340
341bool KateVariableExpansionDialog::eventFilter(QObject *watched, QEvent *event)
342{
343 // filter line edit
344 if (watched == m_filterEdit) {
345 if (event->type() == QEvent::KeyPress) {
346 QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
347 const bool forward2list = (keyEvent->key() == Qt::Key_Up) || (keyEvent->key() == Qt::Key_Down) || (keyEvent->key() == Qt::Key_PageUp)
348 || (keyEvent->key() == Qt::Key_PageDown) || (keyEvent->key() == Qt::Key_Enter) || (keyEvent->key() == Qt::Key_Return);
349 if (forward2list) {
350 QCoreApplication::sendEvent(receiver: m_listView, event);
351 return true;
352 }
353 }
354 return QDialog::eventFilter(watched, event);
355 }
356
357 // tracked widgets (tooltips, adding/removing the showAction)
358 switch (event->type()) {
359 case QEvent::FocusIn: {
360 if (auto lineEdit = qobject_cast<QLineEdit *>(object: watched)) {
361 lineEdit->addAction(action: m_showAction, position: QLineEdit::TrailingPosition);
362 } else if (auto textEdit = qobject_cast<QTextEdit *>(object: watched)) {
363 if (!m_textEditButtons.contains(key: textEdit)) {
364 m_textEditButtons[textEdit] = new TextEditButton(m_showAction, textEdit);
365 }
366 m_textEditButtons[textEdit]->raise();
367 m_textEditButtons[textEdit]->show();
368 }
369 break;
370 }
371 case QEvent::FocusOut: {
372 if (auto lineEdit = qobject_cast<QLineEdit *>(object: watched)) {
373 lineEdit->removeAction(action: m_showAction);
374 } else if (auto textEdit = qobject_cast<QTextEdit *>(object: watched)) {
375 if (m_textEditButtons.contains(key: textEdit)) {
376 delete m_textEditButtons[textEdit];
377 m_textEditButtons.remove(key: textEdit);
378 }
379 }
380 break;
381 }
382 case QEvent::ToolTip: {
383 QString inputText;
384 if (auto lineEdit = qobject_cast<QLineEdit *>(object: watched)) {
385 inputText = lineEdit->text();
386 }
387 QString toolTip;
388 if (!inputText.isEmpty()) {
389 auto activeView = KTextEditor::Editor::instance()->application()->activeMainWindow()->activeView();
390 toolTip = KTextEditor::Editor::instance()->expandText(text: inputText, view: activeView);
391 }
392
393 if (!toolTip.isEmpty()) {
394 auto helpEvent = static_cast<QHelpEvent *>(event);
395 QToolTip::showText(pos: helpEvent->globalPos(), text: toolTip, w: qobject_cast<QWidget *>(o: watched));
396 event->accept();
397 return true;
398 }
399 break;
400 }
401 default:
402 break;
403 }
404
405 // auto-hide on focus change
406 auto parentWindow = parentWidget()->window();
407 const bool keepVisible = isActiveWindow() || m_widgets.contains(t: parentWindow->focusWidget());
408 if (!keepVisible) {
409 hide();
410 }
411
412 return QDialog::eventFilter(watched, event);
413}
414
415// kate: space-indent on; indent-width 4; replace-tabs on;
416

source code of ktexteditor/src/utils/katevariableexpansionhelpers.cpp