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 | */ |
36 | static 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 | |
56 | namespace KateMacroExpander |
57 | { |
58 | QString 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 | |
86 | class VariableItemModel : public QAbstractItemModel |
87 | { |
88 | public: |
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 | |
147 | private: |
148 | QList<KTextEditor::Variable> m_variables; |
149 | }; |
150 | |
151 | class TextEditButton : public QToolButton |
152 | { |
153 | public: |
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 | |
165 | protected: |
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 | |
178 | public: |
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 | |
194 | private: |
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 | |
208 | private: |
209 | QWidget *m_watched; |
210 | }; |
211 | |
212 | KateVariableExpansionDialog::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 ¤t, 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 | |
302 | KateVariableExpansionDialog::~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 | |
312 | void 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 | |
320 | int KateVariableExpansionDialog::isEmpty() const |
321 | { |
322 | return m_variables.isEmpty(); |
323 | } |
324 | |
325 | void 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 | |
333 | void KateVariableExpansionDialog::onObjectDeleted(QObject *object) |
334 | { |
335 | m_widgets.removeAll(t: object); |
336 | if (m_widgets.isEmpty()) { |
337 | deleteLater(); |
338 | } |
339 | } |
340 | |
341 | bool 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 | |