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