| 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 | |